feat(measure): rework workflow operatore — AVVIA, capoturno, fine ciclo, blocco logout

- task_list: pulsante AVVIA sequenziale + rinomina "Inizia Misure" → "Visualizza Task"
- task_execute header: aggiunti "Lista task", "Riepilogo", "Fermo linea", "Fine Produzione"
- task_execute footer: "Fine ciclo misura" (attivo quando tutte le quote misurate) + "Completato"
- Rimosso auto-advance al task successivo, flusso ora esplicito via Completato
- Misura fuori tolleranza: blocco avanzamento + modale login capoturno (validate_supervisor)
- Blocco logout durante misurazioni attive (icona lucchetto su task_execute)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-23 15:54:37 +00:00
parent 0748ce9b1e
commit 1da7e5c7af
6 changed files with 386 additions and 32 deletions
@@ -321,6 +321,33 @@ def save_measurement():
return jsonify(resp), 201
# ---------------------------------------------------------------------------
# Route: Validate supervisor credentials (AJAX)
# ---------------------------------------------------------------------------
@measure_bp.route("/validate-supervisor", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def validate_supervisor():
"""Validate supervisor (capoturno) credentials for out-of-tolerance authorization."""
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return jsonify({"error": True, "detail": "Username e password richiesti"}), 400
resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
if resp.get("error"):
return jsonify({"error": True, "detail": "Credenziali non valide"}), 401
user = resp.get("user", {})
if not user.get("is_admin"):
return jsonify({"error": True, "detail": "Utente non autorizzato (richiesto capoturno)"}), 403
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
# ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly)
# ---------------------------------------------------------------------------
@@ -213,6 +213,15 @@
<div class="border-t border-[var(--border-color)] my-1"></div>
{% if request.endpoint == 'measure.task_execute' %}
<span class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
title="{{ _('Logout bloccato durante le misurazioni') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
{{ _('Logout') }}
</span>
{% else %}
<a href="{{ url_for('auth.logout') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
@@ -221,6 +230,7 @@
</svg>
{{ _('Logout') }}
</a>
{% endif %}
</div>
</div>
{% endif %}
@@ -84,7 +84,7 @@
</a>
{# Task badge + title #}
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800">
@@ -95,6 +95,45 @@
</h1>
</div>
{# Lista task + Riepilogo buttons #}
<div class="shrink-0 flex items-center gap-1.5">
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{{ _('Lista task') }}
</a>
<button @click="goToSummary()"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
{{ _('Riepilogo') }}
</button>
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="subtasks.length > 0">
<div class="shrink-0 flex items-center gap-1.5">
<button @click="showSupervisorModal = true; supervisorAction = 'fermo_linea'"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Fermo linea') }}
</button>
<button @click="showSupervisorModal = true; supervisorAction = 'fine_produzione'"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
{{ _('Fine Produzione') }}
</button>
</div>
</template>
{# Lot + Serial badges #}
<div class="shrink-0 flex items-center gap-2">
{% if lot_number %}
@@ -397,19 +436,47 @@
x-text="Math.round(progressPercent) + '%'"></span>
</div>
{# Right: Summary button #}
<button x-show="isComplete"
x-transition
@click="goToSummary()"
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Riepilogo') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
{# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
<template x-if="subtasks.length > 0">
<div class="flex items-center gap-1.5">
<button x-show="isComplete && !cycleConfirmed"
x-transition
@click="confirmCycle()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-primary text-white hover:bg-primary-700">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Fine ciclo misura') }}
</button>
<button x-show="cycleConfirmed"
x-transition
@click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-measure-pass text-white hover:opacity-90">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</div>
</template>
<template x-if="subtasks.length === 0">
<button @click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-measure-pass text-white hover:opacity-90">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</template>
</div>
</div>
@@ -456,13 +523,77 @@
</div>
</div>
<button @click="goToSummary()"
class="btn btn-primary w-full justify-center gap-2">
{{ _('Vai al Riepilogo') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
<div class="flex gap-2">
<button @click="goToSummary()"
class="btn btn-secondary flex-1 justify-center gap-2">
{{ _('Riepilogo') }}
</button>
<button @click="showCompletionOverlay = false; cycleConfirmed = true"
class="btn btn-primary flex-1 justify-center gap-2">
{{ _('Conferma ciclo') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</button>
</div>
</div>
</div>
{# ================================================================
SUPERVISOR LOGIN MODAL
================================================================ #}
<div x-show="showSupervisorModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="showSupervisorModal = false">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 max-w-sm mx-4 w-full border border-[var(--border-color)]">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Autorizzazione capoturno') }}</h3>
<p class="text-xs text-[var(--text-secondary)]" x-text="supervisorReason"></p>
</div>
</div>
<div class="space-y-3 mb-5">
<div>
<label class="tmf-label text-xs">{{ _('Username') }}</label>
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
placeholder="{{ _('Username capoturno') }}" @keydown.enter="validateSupervisor()">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Password') }}</label>
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
placeholder="••••••••" @keydown.enter="validateSupervisor()">
</div>
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
</div>
<div class="flex gap-2">
<button @click="showSupervisorModal = false; supervisorUsername = ''; supervisorPassword = ''; supervisorError = ''"
class="btn btn-secondary flex-1 text-sm">
{{ _('Annulla') }}
</button>
<button @click="validateSupervisor()"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
class="btn btn-primary flex-1 text-sm gap-1">
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Autorizza') }}
</button>
</div>
</div>
</div>
@@ -498,6 +629,16 @@ function taskExecute() {
errorMessage: '',
showCompletionOverlay: false,
// ---- Cycle & workflow state ----
cycleConfirmed: false,
showSupervisorModal: false,
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
supervisorUsername: '',
supervisorPassword: '',
supervisorError: '',
supervisorValidating: false,
pendingAdvance: false,
// ---- Value from numpad / caliper ----
currentValue: null,
@@ -650,15 +791,17 @@ function taskExecute() {
// Pause to show result feedback
await new Promise(r => setTimeout(r, 1000));
// Check if all done
// Out-of-tolerance: block advancement, require supervisor
if (pf === 'fail') {
this.pendingAdvance = true;
this.supervisorAction = 'out_of_tolerance';
this.showSupervisorModal = true;
return;
}
// Check if all done — show overlay but do NOT auto-navigate
if (this.completedCount >= this.totalSubtasks) {
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
} else {
this.showCompletionOverlay = true;
}
this.showCompletionOverlay = true;
return;
}
@@ -705,6 +848,73 @@ function taskExecute() {
}
},
// ---- Confirm measurement cycle (Fine ciclo misura) ----
confirmCycle() {
this.cycleConfirmed = true;
this.showCompletionOverlay = false;
},
// ---- Navigate to next task (Completato) ----
goToNextTask() {
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
} else {
this.goToSummary();
}
},
// ---- Supervisor reason text ----
get supervisorReason() {
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
return '';
},
// ---- Validate supervisor credentials ----
async validateSupervisor() {
this.supervisorError = '';
this.supervisorValidating = true;
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
});
const data = await resp.json();
if (!resp.ok || data.error) {
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
this.supervisorValidating = false;
return;
}
// Authorized — close modal and proceed
this.showSupervisorModal = false;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorValidating = false;
if (this.supervisorAction === 'out_of_tolerance') {
this.pendingAdvance = false;
if (this.completedCount >= this.totalSubtasks) {
this.showCompletionOverlay = true;
} else {
this.advanceToNext();
}
}
// fermo_linea and fine_produzione are handled by GAIA integration (future)
} catch (err) {
this.supervisorError = '{{ _("Errore di connessione") }}';
this.supervisorValidating = false;
}
},
// ---- Go to summary ----
goToSummary() {
const recipeId = this.task.recipe_id || 0;
@@ -60,8 +60,19 @@
{% endif %}
</div>
<!-- Right: Traceability Badges -->
<div class="flex flex-col gap-2 sm:items-end shrink-0">
<!-- Right: AVVIA + Traceability -->
<div class="flex flex-col gap-3 sm:items-end shrink-0">
{% if tasks %}
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
px-8 py-3 rounded-xl transition-all duration-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA') }}
</a>
{% endif %}
{% if lot_number %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
@@ -185,7 +196,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Inizia Misure') }}
{{ _('Visualizza Task') }}
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
@@ -699,6 +699,54 @@ msgstr "Save settings"
msgid "Salvato"
msgstr "Saved"
msgid "AVVIA"
msgstr "START"
msgid "Visualizza Task"
msgstr "View Task"
msgid "Lista task"
msgstr "Task list"
msgid "Fermo linea"
msgstr "Line stop"
msgid "Fine Produzione"
msgstr "End Production"
msgid "Fine ciclo misura"
msgstr "End measurement cycle"
msgid "Completato"
msgstr "Completed"
msgid "Conferma ciclo"
msgstr "Confirm cycle"
msgid "Autorizzazione capoturno"
msgstr "Supervisor authorization"
msgid "Misurazione fuori tolleranza"
msgstr "Measurement out of tolerance"
msgid "Fermo linea richiesto"
msgstr "Line stop requested"
msgid "Fine produzione richiesta"
msgstr "End of production requested"
msgid "Username capoturno"
msgstr "Supervisor username"
msgid "Credenziali non valide o utente non autorizzato"
msgstr "Invalid credentials or unauthorized user"
msgid "Autorizza"
msgstr "Authorize"
msgid "Logout bloccato durante le misurazioni"
msgstr "Logout blocked during measurements"
msgid "Sessione in scadenza"
msgstr "Session expiring"
@@ -729,6 +729,54 @@ msgstr "Salva impostazioni"
msgid "Salvato"
msgstr "Salvato"
msgid "AVVIA"
msgstr "AVVIA"
msgid "Visualizza Task"
msgstr "Visualizza Task"
msgid "Lista task"
msgstr "Lista task"
msgid "Fermo linea"
msgstr "Fermo linea"
msgid "Fine Produzione"
msgstr "Fine Produzione"
msgid "Fine ciclo misura"
msgstr "Fine ciclo misura"
msgid "Completato"
msgstr "Completato"
msgid "Conferma ciclo"
msgstr "Conferma ciclo"
msgid "Autorizzazione capoturno"
msgstr "Autorizzazione capoturno"
msgid "Misurazione fuori tolleranza"
msgstr "Misurazione fuori tolleranza"
msgid "Fermo linea richiesto"
msgstr "Fermo linea richiesto"
msgid "Fine produzione richiesta"
msgstr "Fine produzione richiesta"
msgid "Username capoturno"
msgstr "Username capoturno"
msgid "Credenziali non valide o utente non autorizzato"
msgstr "Credenziali non valide o utente non autorizzato"
msgid "Autorizza"
msgstr "Autorizza"
msgid "Logout bloccato durante le misurazioni"
msgstr "Logout bloccato durante le misurazioni"
msgid "Sessione in scadenza"
msgstr "Sessione in scadenza"