Files
TieMeasureFlow/src/frontend/flask_app/templates/measure/task_execute.html
T
Adriano Dal Pastro 98b1c5ae85 feat(measure): l'operatore entra nella sequenza, non in un elenco
Punto 10. Sceglere una ricetta apriva la lista completa dei task e chiedeva
all'operatore di decidere da dove cominciare prima ancora di aver fatto
qualcosa. Ora AVVIA IN SEQUENZA porta dentro il primo task; la lista resta,
un livello sotto, per quando serve vederla tutta o tornare indietro.

- nuova rotta /measure/start/<ricetta>: memorizza lotto e seriale (prima lo
  faceva la lista, che ora si salta), verifica la tracciabilità obbligatoria
  e apre il primo task
- GET /api/measurements/task-progress: quante quote ha già preso ogni task di
  misura, contate per quota e non per tentativo, delimitate dalla produzione
  aperta o, fuori produzione, dall'operatore
- la lista distingue «Incompiuto 1/3» da «Completato 3/3»: un task lasciato a
  metà non somigliava più a uno mai aperto
- «Visualizza singolo TASK» non è più riservato al Maker — dire «incompiuto»
  senza dare la strada per tornarci sarebbe una lamentela, non una funzione
- nel task «Riepilogo» diventa «Completato» e si apre solo quando il ciclo è
  chiuso: altrimenti sarebbe la scorciatoia che rende facoltative le quote
- «Fine ciclo misura» si vede da subito, spento, e dice quante quote mancano;
  prima compariva a task già finito, quando non serviva più saperlo

Il pulsante verde del footer diventa «Task successivo»: due bottoni con la
stessa parola addosso sono uno di troppo.

Test: 334 (erano 309). Il side effect dei mock del client Flask ora risponde
per endpoint invece che per turno — una lista posizionale si rompe appena una
pagina fa una domanda in più al server, che è come crescono tutte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:31:42 +00:00

1784 lines
81 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
{# Full-screen layout: no body scroll, no footer, main fills available space #}
{% block body_class %}h-screen overflow-hidden{% endblock %}
{% block wrapper_class %}h-full{% endblock %}
{% block main_class %}flex-1 min-h-0{% endblock %}
{% block footer %}{% endblock %}
{% block extra_head %}
<style>
/* Annotation viewer container */
.annotation-container {
position: relative;
width: 100%;
height: 100%;
background: var(--bg-secondary);
overflow: hidden;
}
.annotation-container canvas {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
/* Tolerance bar gradient background */
.tolerance-bar-bg {
background: linear-gradient(to right,
rgba(220, 38, 38, 0.15) 0%,
rgba(217, 119, 6, 0.15) 15%,
rgba(5, 150, 105, 0.15) 30%,
rgba(5, 150, 105, 0.15) 70%,
rgba(217, 119, 6, 0.15) 85%,
rgba(220, 38, 38, 0.15) 100%
);
}
/* Pulse animation for active marker */
@keyframes markerPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
}
.marker-active-pulse {
animation: markerPulse 2s ease-in-out infinite;
}
/* Left sidebar scrollbar */
.sidebar-markers::-webkit-scrollbar { width: 3px; }
.sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
/* Right panel scrollbar */
.right-panel::-webkit-scrollbar { width: 4px; }
.right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
</style>
{% endblock %}
{% block content %}
<script>
window.__taskData = {{ task|tojson }};
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
window.__allTaskIds = {{ all_task_ids|tojson }};
</script>
<div class="h-full flex flex-col overflow-hidden"
x-data="taskExecute()"
x-init="init()"
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@numpad-rejected.window="onNumpadRejected($event.detail)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
{# ================================================================
HEADER — Compact task info bar
================================================================ #}
<div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
<div class="px-3 py-2 flex items-center gap-3">
{# Back button #}
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
{# Task badge + title #}
<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">
Task {{ (task.order_index or 0) + 1 }}
</span>
<h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
{{ task.title or _('Task di misurazione') }}
</h1>
</div>
{#
Lista task + Completato.
The list is the second level now: the operator arrives inside the sequence
and comes here when they want the whole of it. What used to sit beside it
was «Riepilogo», which jumped out of the sequence to a recap - the wrong
thing to offer someone halfway through a task. The way forward is
«Completato», and on a measurement task it stays shut until the cycle is
closed, which is the same rule the footer enforces.
#}
<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="goToNextTask()"
:disabled="!canLeaveTask"
:title="canLeaveTask ? '' : blockedReason"
:class="canLeaveTask ? '' : 'opacity-40 cursor-not-allowed'"
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="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
</button>
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="isMeasureTask">
<div class="shrink-0 flex items-center gap-1.5">
{# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #}
<button x-show="!isPaused"
@click="openSupervisorModal('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 x-show="isPaused" x-cloak
@click="openSupervisorModal('ripresa')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-emerald-500 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"/>
</svg>
{{ _('Riprendi') }}
</button>
<button @click="openSupervisorModal('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 %}
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
text-amber-800 dark:text-amber-200">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<span class="font-mono font-semibold">{{ lot_number }}</span>
</div>
{% endif %}
{% if serial_number %}
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
text-indigo-800 dark:text-indigo-200">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
<span class="font-mono font-semibold">{{ serial_number }}</span>
</div>
{% endif %}
{# Caliper status #}
{% include "components/caliper_status.html" %}
</div>
</div>
</div>
{# ================================================================
MAIN — 3-column layout
================================================================ #}
<div class="flex-1 flex overflow-hidden">
{# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
────────────────────────────────────────────── #}
<div x-show="isMeasureTask && subtasks.length > 0"
class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
<template x-for="(st, idx) in subtasks" :key="st.id">
<button @click="goToSubtask(idx)"
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
:class="idx === currentIndex
? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
: 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
{# Marker circle #}
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
:class="idx === currentIndex
? 'bg-primary text-white shadow-md marker-active-pulse'
: getMeasurementStatus(st.id) === 'pass'
? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
: getMeasurementStatus(st.id) === 'fail'
? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
: getMeasurementStatus(st.id) === 'warning'
? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
x-text="st.marker_number"></span>
{# Status icon below circle #}
<template x-if="getMeasurementStatus(st.id) === 'pass'">
<svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'fail'">
<svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'warning'">
<svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</template>
</button>
</template>
</div>
{# ──────────────────────────────────────────────
CENTER — Image area
────────────────────────────────────────────── #}
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
{# What the task says to do. It was written for the operator and until now
only the maker ever saw it; the line breaks and the bold of the technical
sheet survive here as they do in the editor. #}
{% if task.directive or task.description %}
<div class="shrink-0 max-h-36 overflow-y-auto px-4 py-2.5
bg-[var(--bg-card)] border-b border-[var(--border-color)]">
{% if task.directive %}
<p class="text-sm font-semibold text-[var(--text-primary)] leading-snug">
{{ task.directive|rich_text }}
</p>
{% endif %}
{% if task.description %}
<div class="text-sm text-[var(--text-secondary)] leading-relaxed
{% if task.directive %}mt-1{% endif %}">
{{ task.description|rich_text }}
</div>
{% endif %}
</div>
{% endif %}
{# Main image area #}
<div class="flex-1 overflow-hidden relative">
{# Option A: Subtask has its own image → plain <img> #}
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
<img :src="'/measure/api/files/' + currentSubtaskImage"
alt="{{ _('Immagine dettaglio misura') }}"
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
</div>
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
<div x-show="showTaskImage" class="absolute inset-0">
{% if task.file_path %}
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/measure/api/files/{{ task.file_path }}';
annotations = window.__taskAnnotations;
$nextTick(() => init());
"
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
<canvas x-ref="annotationCanvas"
@click="handleClick($event)"
class="cursor-pointer"></canvas>
</div>
{% endif %}
</div>
{# Option C: No image → placeholder #}
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
</div>
</div>
</div>
</div>
{# ──────────────────────────────────────────────
RIGHT PANEL — Info + tolerances + numpad (hidden when no subtasks)
────────────────────────────────────────────── #}
<div x-show="isMeasureTask && subtasks.length > 0"
class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
{# ---- Subtask header ---- #}
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
bg-primary text-white font-bold text-sm shadow marker-active-pulse"
x-text="currentSubtask?.marker_number"></span>
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
<span class="text-[11px] text-[var(--text-muted)]"
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
</div>
<span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
</span>
</div>
</div>
{# ---- Tolerance parameters (compact grid) ---- #}
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
{# Nominal row #}
<div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
<span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
<span class="font-mono font-bold text-primary text-sm"
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
</div>
{# 2x2 tolerance grid #}
<div class="grid grid-cols-2 gap-1 text-[11px]">
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
<span class="text-measure-fail font-medium">UTL</span>
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
<span class="text-measure-fail font-medium">LTL</span>
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-measure-warning font-medium">UWL</span>
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-measure-warning font-medium">LWL</span>
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
</div>
</div>
</div>
{# ---- Tolerance bar (compact) ---- #}
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
{# Zone labels #}
<div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
<span x-text="currentSubtask?.utl?.toFixed(2)"></span>
</div>
{# Center line #}
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
{# Value needle #}
<div x-show="currentValue !== null"
x-transition
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"
:style="'left: calc(' + progressWidth + '% - 2px)'">
<div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"></div>
</div>
</div>
</div>
{# ---- Already measured indicator ---- #}
<div x-show="isMeasured(currentSubtask?.id)"
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span>{{ _('Registrata') }}:</span>
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
<span x-text="currentSubtask?.unit || 'mm'"></span>
</div>
</div>
{# ---- Measurement feedback ---- #}
<div class="px-3 pt-2" x-show="currentSubtask" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
get utl() { return currentSubtask?.utl || 0; },
get uwl() { return currentSubtask?.uwl || 0; },
get lwl() { return currentSubtask?.lwl || 0; },
get ltl() { return currentSubtask?.ltl || 0; },
get unit() { return currentSubtask?.unit || 'mm'; } }">
{% include "components/measurement_feedback.html" %}
</div>
{# ---- Numpad (only when a subtask is active) ---- #}
<div class="px-3 py-2 relative flex-1" x-show="currentSubtask">
{# Saving overlay #}
<div x-show="saving"
x-transition
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
<div class="flex items-center gap-2 text-primary font-medium text-sm">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ _('Salvataggio...') }}
</div>
</div>
{% include "components/numpad.html" %}
</div>
{# ---- Next measurement indicator ---- #}
<div class="px-3 pb-2" x-show="currentSubtask">
{% include "components/next_measurement.html" %}
</div>
{# ---- Error message ---- #}
<div x-show="errorMessage"
x-transition
x-cloak
class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="errorMessage"></span>
</div>
</div>
</div>
</div>
{# ================================================================
MEASUREMENT TIMER BANNER
================================================================ #}
{# Past zero the counter turns round: it stops saying how long is left and starts
saying how late the line is, in red. A delay has to be read, not deduced. #}
<div x-show="timerActive"
x-transition
x-cloak
class="shrink-0 border-t px-4 py-2"
:class="isOverdue
? 'bg-red-50 dark:bg-red-900/30 border-red-400 dark:border-red-700'
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'">
<div class="flex items-center justify-center gap-3">
<svg class="w-5 h-5 animate-pulse"
:class="isOverdue ? 'text-red-600' : 'text-amber-600'"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-show="!isOverdue" class="text-sm font-medium text-amber-800 dark:text-amber-200">
{{ _('Prossima misurazione tra') }}
</span>
<span x-show="isOverdue" class="text-sm font-bold text-red-800 dark:text-red-200">
{{ _('Misurazione in ritardo di') }}
</span>
<span class="text-lg font-bold font-mono px-2 py-0.5 rounded"
:class="isOverdue
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900/40'
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40'"
x-text="timerDisplay"></span>
<span class="text-xs"
:class="isOverdue ? 'text-red-600 dark:text-red-300' : 'text-amber-600 dark:text-amber-400'">
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
</span>
</div>
</div>
{# ================================================================
MISURA SENZA QUOTE — declared a measurement task but none configured yet.
Under the old rule this screen quietly looked like a note; now the task keeps
its identity and says what is missing instead.
================================================================ #}
<div x-show="isMeasureTaskWithoutQuotes"
x-transition
x-cloak
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
<div class="flex items-center justify-center gap-2">
<svg class="w-4 h-4 text-amber-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
<span class="text-sm text-amber-800 dark:text-amber-200">
{{ _('Task di misura senza quote configurate: la ricetta è incompleta') }}
</span>
</div>
</div>
{# ================================================================
QUOTA FUORI TOLLERANZA — nothing moves from here without the capoturno or a
new reading of this same quote. Kept on screen after the modal is dismissed,
because the block outlives the modal: the server refuses the next value.
================================================================ #}
<div x-show="blockedByTolerance"
x-transition
x-cloak
class="shrink-0 bg-red-50 dark:bg-red-900/30 border-t-2 border-red-500 px-4 py-2">
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
<svg class="w-5 h-5 text-red-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
<span class="text-sm font-semibold text-red-900 dark:text-red-100">
{{ _('Quota fuori tolleranza') }}
</span>
<span class="text-xs text-red-800 dark:text-red-200">
{{ _('serve il capoturno, oppure una nuova misura della stessa quota') }}
</span>
<button @click="openSupervisorModal('out_of_tolerance')"
class="btn text-xs py-1 px-2.5 bg-red-600 hover:bg-red-700 text-white">
{{ _('Autorizza') }}
</button>
</div>
</div>
{# ================================================================
LINEA FERMA — the countdown is frozen, not merely hidden. Stated plainly
because a stopped line that looks like a running one is how a measurement
interval gets silently missed.
================================================================ #}
<div x-show="isPaused"
x-transition
x-cloak
class="shrink-0 bg-amber-100 dark:bg-amber-900/40 border-t-2 border-amber-500 px-4 py-2">
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
<svg class="w-5 h-5 text-amber-700 dark:text-amber-300 shrink-0" 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>
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
{{ _('Linea ferma') }}
</span>
{# A line stopped past its interval is frozen at a delay, not at a wait. Both
read as mm:ss, so the wording is what has to change. #}
<span class="text-xs text-amber-800 dark:text-amber-200">
<span x-show="timerRemaining > 0">{{ _('Il conto alla rovescia è congelato a') }}</span>
<span x-show="timerRemaining <= 0">{{ _('Misurazione in ritardo di') }}</span>
<span class="font-mono font-bold" x-text="timerDisplay"></span>
— {{ _('serve il capoturno per riprendere') }}
</span>
</div>
</div>
{# ================================================================
PRODUCTION ERROR — the server refused to record the production state.
Shown because the fallback keeps the operator working locally, and a timer
that is not backed by the server must never look like one that is.
================================================================ #}
<div x-show="productionError"
x-transition
x-cloak
class="shrink-0 bg-red-50 dark:bg-red-900/20 border-t border-red-300 dark:border-red-700 px-4 py-2">
<div class="flex items-center justify-center gap-2">
<svg class="w-4 h-4 text-red-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
<span class="text-sm text-red-800 dark:text-red-200">
{{ _('Produzione non registrata sul server') }}:
<span x-text="productionError"></span>
</span>
</div>
</div>
{# ================================================================
AVVIO PRODUZIONE — visible after first cycle, before production started
================================================================ #}
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && isMeasureTask"
x-transition
x-cloak
class="shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-3">
<div class="flex items-center justify-center">
<button @click="startProduction()"
class="btn gap-2 px-6 py-2.5 text-sm font-bold
bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl
rounded-xl transition-all duration-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('Avvio Produzione') }}
</button>
</div>
<p class="text-xs text-center text-[var(--text-muted)] mt-1.5">
{{ _('Invia segnale al gestionale per avviare il timer della linea') }}
</p>
</div>
<div x-show="productionStarted && isMeasureTask"
x-transition
x-cloak
class="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-1.5">
<div class="flex items-center justify-center gap-2 text-xs text-emerald-700 dark:text-emerald-300">
<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>
<span class="font-medium">{{ _('Produzione avviata') }}</span>
</div>
</div>
{# ================================================================
FOOTER — Progress bar + navigation
================================================================ #}
<div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
<div class="px-3 py-2 flex items-center gap-3">
{# Left: Back #}
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.5">
<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 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<span class="hidden sm:inline">{{ _('Task') }}</span>
</a>
{# Center: Progress bar #}
<div class="flex-1 flex items-center gap-2">
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
</span>
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 ease-out"
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
:style="'width: ' + progressPercent + '%'"></div>
</div>
<span class="text-xs font-bold shrink-0"
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
x-text="Math.round(progressPercent) + '%'"></span>
</div>
{# Right: Fine ciclo misura (measurement tasks) / Task successivo (after cycle) #}
<template x-if="isMeasureTask && subtasks.length > 0">
<div class="flex items-center gap-1.5">
{#
Shown from the start, and greyed until every quote has a value. It used
to appear only once the task was finished, which left the operator with
no sign that the button existed or what would bring it out - a control
that is missing teaches nothing, one that is disabled says what is left.
#}
<button x-show="!cycleConfirmed"
x-transition
@click="confirmCycle()"
:disabled="!isComplete"
:title="isComplete ? '' : missingQuotesLabel"
:class="isComplete
? 'bg-primary text-white hover:bg-primary-700 shadow-md'
: 'bg-steel-200 dark:bg-steel-700 text-steel-500 dark:text-steel-400 cursor-not-allowed'"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3">
<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>
<span x-text="isComplete
? {{ _('Fine ciclo misura')|tojson_attr }}
: missingQuotesLabel"></span>
</button>
{# Girare il pezzo e rimisurare, senza chiudere il ciclo: a second reading
of the same part is not a new one, and must not restart the interval. #}
<button x-show="isComplete && productionStarted"
x-transition
@click="remeasure()"
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-3">
<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="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
</svg>
{{ _('Rimisura') }}
</button>
{# The cycle is closed; this only walks on. «Completato» is the header's
word for finishing a task, and two buttons wearing it would be one too
many. #}
<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>
{{ _('Task successivo') }}
<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="!isMeasureTask || 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>
{# ================================================================
È ORA DI MISURARE — the interval expired on a task that is not the
measurement. The operator is told why the screen is about to change, then
taken there; there is no way to dismiss it, because putting the measurement
off is exactly what the interval exists to prevent.
================================================================ #}
<div x-show="announcedRedirect"
x-transition
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center border-2 border-red-500">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<svg class="w-8 h-8 text-red-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('È ora di misurare') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-5">
{{ _('Ritorno alla misurazione tra') }}
<span class="font-bold font-mono" x-text="redirectIn"></span> s
</p>
<button @click="goToMeasurementTask()"
class="btn w-full justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-bold py-2.5">
{{ _('Vai alla misura') }}
<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="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
</div>
{# ================================================================
COMPLETION OVERLAY
================================================================ #}
<div x-show="showCompletionOverlay"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center"
x-transition:enter="transition ease-out duration-300 delay-100"
x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" 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>
</div>
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('Misurazioni Complete') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-2">
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
</p>
<div class="flex justify-center gap-4 mb-5">
<div class="text-center">
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Conformi') }}</div>
</div>
<div class="text-center" x-show="warningCount > 0">
<div class="text-xl font-bold font-mono text-measure-warning" x-text="warningCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Attenzione') }}</div>
</div>
<div class="text-center" x-show="failCount > 0">
<div class="text-xl font-bold font-mono text-measure-fail" x-text="failCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Non Conf.') }}</div>
</div>
</div>
<div class="flex gap-2">
<button @click="goToSummary()"
class="btn btn-secondary flex-1 justify-center gap-2">
{{ _('Riepilogo') }}
</button>
{# Production phase: confirm the cycle. Goes through confirmCycle so the
server records it - setting the flag here only looked like it had. #}
<button x-show="productionStarted"
@click="confirmCycle()"
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>
{# START phase, more tasks to go: advance without blocking or exiting #}
<button x-show="!productionStarted && !isLastTask"
@click="goToNextTask()"
class="btn btn-primary flex-1 justify-center gap-2">
{{ _('Task successivo') }}
<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="M9 5l7 7-7 7"/>
</svg>
</button>
{# START phase, last task: start production #}
<button x-show="!productionStarted && isLastTask"
@click="startProductionFromOverlay()"
class="btn flex-1 justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white">
{{ _('Avvio Produzione') }}
<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 3l14 9-14 9V3z"/>
</svg>
</button>
</div>
{# The piece can be turned over here, where the operator actually is when the
quotes are done - not only from the bar behind the overlay. #}
<button x-show="productionStarted"
@click="remeasure()"
class="mt-3 w-full text-sm text-[var(--text-secondary)] hover:text-primary
inline-flex items-center justify-center gap-1.5 py-1">
<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="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
</svg>
{{ _('Girare il pezzo e rimisurare') }}
</button>
</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="closeSupervisorModal()">
<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="submitSupervisor()">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Password') }}</label>
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
placeholder="••••••••" @keydown.enter="submitSupervisor()">
</div>
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
</div>
<div class="flex gap-2">
{# Out of tolerance, the way out of this modal is measuring the quote
again - not cancelling. Closing it changes nothing: the quote stays
blocked, and the banner behind says so. #}
<button @click="closeSupervisorModal()"
class="btn btn-secondary flex-1 text-sm">
<span x-show="supervisorAction !== 'out_of_tolerance'">{{ _('Annulla') }}</span>
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
</button>
<button @click="submitSupervisor()"
: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>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';
</script>
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
{# The clock's rules - how a delay reads, when the buzzer goes, where the
measurement lives - are shared with the other screens rather than restated. #}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
<script>
/**
* Task Execute - Main Alpine.js component
* Manages measurement workflow state, save logic, and navigation.
* Layout: 3-column (marker sidebar | image center | info+numpad right)
*/
function taskExecute() {
return {
// ---- Data from server ----
task: window.__taskData,
subtasks: window.__taskSubtasks,
lotNumber: '{{ lot_number or '' }}',
serialNumber: '{{ serial_number or '' }}',
// ---- Measurement state ----
currentIndex: 0,
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
saving: false,
errorMessage: '',
showCompletionOverlay: false,
// ---- Measurement timer ----
// The interval is only used for display: the deadline itself lives on the server,
// so it survives the page. See loadProductionRun().
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
timerActive: false,
timerRemaining: 0,
_timerInterval: null,
cycleCount: 0,
productionStarted: false,
// ---- Production run (server-side state) ----
// Navigating between tasks is a full page load, so anything kept only here dies.
// This is read back from the server on every load instead.
productionRun: null,
productionError: '',
// The measurement tasks of this recipe, in order: the loop the operator stays
// in once production starts, and what says which task closes a cycle.
measurementTaskIds: [],
// The interval has elapsed and it has been said out loud - buzzer, and the
// operator on their way back to the measurement. Kept so it is said once.
dueAnnounced: false,
redirectIn: 0,
// ---- Cycle & workflow state ----
cycleConfirmed: false,
showSupervisorModal: false,
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
supervisorUsername: '',
supervisorPassword: '',
supervisorError: '',
supervisorValidating: false,
/* The out-of-tolerance measurement waiting for the capoturno:
{ id, subtask_id }, or null. While it is set the operator stays on that
quote - the server refuses the next one anyway. */
pendingFail: null,
// ---- Value from numpad / caliper ----
currentValue: null,
// ---- Input timing (ms spent on the active subtask) ----
inputStartedAt: null,
// ---- Image switching logic ----
get currentSubtaskImage() {
return this.currentSubtask?.image_path || null;
},
get showSubtaskImage() {
return !!this.currentSubtaskImage;
},
get showTaskImage() {
return !this.currentSubtaskImage && !!this.task.file_path;
},
// ---- Computed properties ----
get currentSubtask() {
return this.subtasks[this.currentIndex] || null;
},
get nextSubtask() {
return this.subtasks[this.currentIndex + 1] || null;
},
get totalSubtasks() {
return this.subtasks.length;
},
get completedCount() {
return this.measurements.length;
},
get isComplete() {
return this.completedCount >= this.totalSubtasks;
},
get progressPercent() {
return this.totalSubtasks > 0
? (this.completedCount / this.totalSubtasks * 100)
: 0;
},
/* How many quotes are still owed, worded rather than counted down in silence.
This is what the greyed «Fine ciclo misura» says instead of nothing. */
get missingQuotes() {
return Math.max(0, this.totalSubtasks - this.completedCount);
},
get missingQuotesLabel() {
const n = this.missingQuotes;
return n === 1
? {{ _('Manca 1 quota')|tojson }}
: {{ _('Mancano')|tojson }} + ' ' + n + ' ' + {{ _('quote')|tojson }};
},
/* Whether «Completato» can take the operator on.
A documental task is done when they say it is. A measurement task is done
when its cycle is closed - otherwise the button would be a way round «Fine
ciclo misura», and the quotes it exists to collect would be optional. */
get canLeaveTask() {
if (this.blockedByTolerance) return false;
if (this.isMeasureTask && this.subtasks.length > 0) {
return this.cycleConfirmed;
}
return true;
},
get blockedReason() {
if (this.blockedByTolerance) {
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
}
if (!this.isComplete) return this.missingQuotesLabel;
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
},
// ---- Pass/fail logic ----
get passFailStatus() {
if (this.currentValue === null || !this.currentSubtask) return null;
const v = this.currentValue;
const s = this.currentSubtask;
if (s.utl != null && v > s.utl) return 'fail';
if (s.ltl != null && v < s.ltl) return 'fail';
if (s.uwl != null && v > s.uwl) return 'warning';
if (s.lwl != null && v < s.lwl) return 'warning';
return 'pass';
},
get deviation() {
if (this.currentValue === null || !this.currentSubtask) return null;
return this.currentValue - (this.currentSubtask.nominal || 0);
},
get progressWidth() {
if (!this.currentSubtask || this.currentValue === null) return 50;
const s = this.currentSubtask;
if (s.utl == null || s.ltl == null) return 50;
const range = s.utl - s.ltl;
if (range <= 0) return 50;
const pos = (this.currentValue - s.ltl) / range * 100;
return Math.max(0, Math.min(100, pos));
},
// ---- Summary counts ----
get passCount() {
return this.measurements.filter(m => m.pass_fail === 'pass').length;
},
get warningCount() {
return this.measurements.filter(m => m.pass_fail === 'warning').length;
},
get failCount() {
return this.measurements.filter(m => m.pass_fail === 'fail').length;
},
// ---- Init ----
async init() {
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
this.inputStartedAt = Date.now();
// Rejoin whatever production is already open at this station.
await this.loadProductionRun();
await this.loadPendingAuthorisation();
},
/* Is a quote already waiting for the capoturno?
Reloading the page was the simplest way past the old gate. It is not one
now - the server refuses the next measurement either way - so the screen
picks the block back up as soon as it opens, on the quote it belongs to. */
async loadPendingAuthorisation() {
if (!this.isMeasureTask || !this.subtasks.length) return;
try {
const params = new URLSearchParams({ version_id: this.task.version_id });
if (this.productionRun) {
params.set('production_run_id', this.productionRun.id);
}
const resp = await fetch(
'{{ url_for("measure.api_pending_authorisation") }}?' + params.toString(),
);
if (!resp.ok) return;
const pending = await resp.json();
if (!pending || !pending.id) return;
this.pendingFail = { id: pending.id, subtask_id: pending.subtask_id };
const idx = this.subtasks.findIndex(s => s.id === pending.subtask_id);
if (idx !== -1) this.currentIndex = idx;
this.openSupervisorModal('out_of_tolerance');
} catch (e) {
// Offline: the banner is missing, but so is the ability to save anything.
}
},
// ---- Production run: read the state back from the server ----
async loadProductionRun() {
try {
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
if (!resp.ok) return;
const run = await resp.json();
if (run && run.id) this.adoptProductionRun(run);
} catch (e) {
// Offline or server down: leave the page usable, just without the timer.
}
},
/* Take the server's word for the state of the production. Called on load and
after every action that changes it, so the page never drifts from the truth. */
adoptProductionRun(run) {
this.productionRun = run;
this.productionStarted = run.status !== 'closed';
this.cycleCount = run.cycle_count;
this.measurementTaskIds = run.measurement_task_ids || [];
const seconds = run.seconds_to_next_measurement;
if (seconds === null || seconds === undefined) {
this.stopMeasurementTimer();
return;
}
if (run.status !== 'running') {
// Paused: keep the figure on screen, frozen, so the banner can show how
// much of the interval is being held rather than going blank.
this.stopMeasurementTimer();
this.timerRemaining = seconds;
return;
}
// The server hands over seconds already computed rather than a timestamp: a
// naive datetime would be read in the browser's timezone and the countdown
// would be off by the UTC offset.
this.startCountdownFrom(seconds);
},
// ---- Check if a subtask has been measured ----
isMeasured(subtaskId) {
return this.measurements.some(m => m.subtask_id === subtaskId);
},
getMeasuredValue(subtaskId) {
const m = this.measurements.find(m => m.subtask_id === subtaskId);
return m ? m.value : null;
},
getMeasurementStatus(subtaskId) {
const m = this.measurements.find(m => m.subtask_id === subtaskId);
return m ? m.pass_fail : null;
},
/* The keypad refused a value because this recipe wants the caliper. Said on
the same line as any other measurement error, so there is one place to look
when a value does not go in. */
onNumpadRejected(detail) {
if (detail && detail.reason === 'manual_not_allowed') {
this.errorMessage = '{{ _("Questa ricetta non ammette valori digitati: usare il calibro") }}';
}
},
// ---- Handle numpad confirm ----
async handleMeasurement(value, inputMethod) {
if (!this.currentSubtask || this.saving) return;
this.currentValue = value;
this.errorMessage = '';
const pf = this.passFailStatus;
const dev = this.deviation;
const inputDurationMs = this.inputStartedAt !== null
? Math.max(0, Math.round(Date.now() - this.inputStartedAt))
: null;
this.saving = true;
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const response = await fetch('{{ url_for("measure.save_measurement") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
subtask_id: this.currentSubtask.id,
version_id: this.task.version_id,
value: value,
lot_number: this.lotNumber,
serial_number: this.serialNumber,
input_method: inputMethod || 'manual',
input_duration_ms: inputDurationMs,
// Ties the value to the production under way, so fine produzione can
// emit the statistics file for the whole run.
production_run_id: this.productionRun ? this.productionRun.id : null,
}),
});
const result = await response.json();
if (!response.ok || result.error) {
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
this.saving = false;
return;
}
// Record measurement locally
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
if (existingIdx !== -1) {
this.measurements.splice(existingIdx, 1, mEntry);
} else {
this.measurements.push(mEntry);
}
this.saving = false;
// Pause to show result feedback
await new Promise(r => setTimeout(r, 1000));
// Out of tolerance: nothing moves until the capoturno says so, or until
// this same quote is measured again and comes out inside tolerance.
if (pf === 'fail') {
this.pendingFail = { id: result.id, subtask_id: this.currentSubtask.id };
this.openSupervisorModal('out_of_tolerance');
return;
}
// A second reading of the blocked quote, this time in tolerance: the quote
// is in tolerance now, so the block goes. Both readings stay on record -
// how many attempts are allowed is point 4's business, not this one's.
if (this.pendingFail && this.pendingFail.subtask_id === this.currentSubtask.id) {
this.pendingFail = null;
}
// Check if all done
if (this.completedCount >= this.totalSubtasks) {
// START run (production not yet started): don't block with the
// "Misurazioni Complete" overlay — just advance to the next task.
// The overlay is shown only on the last task (to offer "Avvio
// Produzione") or once production is running ("Conferma ciclo").
if (!this.productionStarted && !this.isLastTask) {
this.goToNextTask();
return;
}
this.showCompletionOverlay = true;
return;
}
this.advanceToNext();
} catch (err) {
console.error('Save measurement error:', err);
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
this.saving = false;
}
},
// ---- Advance to next unmeasured subtask ----
advanceToNext() {
this.currentValue = null;
this.inputStartedAt = Date.now();
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i;
return;
}
}
for (let i = 0; i < this.currentIndex; i++) {
if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i;
return;
}
}
},
/* Out of tolerance and nobody has authorised it: the operator stays put.
This is the whole of point 5. The screen used to open a modal that could be
dismissed, and the measurement was already saved by then, so a value outside
tolerance could be left behind by clicking the backdrop. The server refuses
the next measurement now; these checks only spare the operator the trip. */
get blockedByTolerance() {
return this.pendingFail !== null;
},
/* Say why nothing is moving, and offer the way out. */
refuseWhileBlocked() {
// |tojson, not quotes: the Italian carries an apostrophe, and an apostrophe
// inside a single-quoted literal is how every Alpine binding on the page dies.
this.errorMessage = {{ _("Quota fuori tolleranza: serve l'autorizzazione del capoturno, oppure misurare di nuovo la quota")|tojson }};
this.openSupervisorModal('out_of_tolerance');
},
// ---- Navigation ----
goToSubtask(index) {
if (index < 0 || index >= this.totalSubtasks) return;
// Moving to another quote is exactly what a blocked quote forbids; going
// back to the blocked one to measure it again is not.
if (this.blockedByTolerance
&& this.subtasks[index].id !== this.pendingFail.subtask_id) {
this.refuseWhileBlocked();
return;
}
this.currentIndex = index;
this.currentValue = null;
this.errorMessage = '';
this.inputStartedAt = Date.now();
},
goToSubtaskByMarker(markerNumber) {
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
if (idx !== -1) {
this.goToSubtask(idx);
}
},
// ---- Confirm measurement cycle (Fine ciclo misura) ----
/* A cycle spans every measurement task of the recipe, so this one only closes
it if it is the last. The server decides that - it knows the sequence - and
the cycle count coming back tells us which of the two happened. */
async confirmCycle() {
// Closing the cycle would be another way of walking past a quote that is out
// of tolerance. The server refuses it too.
if (this.blockedByTolerance) {
this.refuseWhileBlocked();
return;
}
this.cycleConfirmed = true;
this.showCompletionOverlay = false;
// Recorded server-side so the count and the next deadline outlive this page.
if (this.productionRun) {
const countBefore = this.cycleCount;
const run = await this.postProduction(
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
{ task_id: this.task.id },
);
if (run) {
this.adoptProductionRun(run);
// Cycle still open: on to the next measurement task, interval untouched.
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
this.goToTask(this.nextMeasurementTaskId);
}
return;
}
}
// No production open (recipe run outside a production): keep the old local
// behaviour rather than leaving the operator without a timer.
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
}
},
/* Rimisura: turn the piece over and measure it again, inside the same cycle.
Not a new cycle - the deadline and the count stay where they are, so a second
reading of the same part does not buy another interval's worth of time. Both
readings stay in the statistics: that is what they are for. */
async remeasure() {
if (this.productionRun) {
const run = await this.postProduction(
'{{ url_for("measure.api_remeasure", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
{ task_id: this.task.id },
);
// Refused - the line is stopped, or the production is closed. The screen
// stays as it is: clearing it would hide the reason.
if (!run) return;
this.adoptProductionRun(run);
}
this.resetForNewCycle();
},
/* POST to a production endpoint, returning the updated run or null on failure. */
async postProduction(url, body) {
this.productionError = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify(body || {}),
});
const data = await resp.json().catch(() => null);
if (!resp.ok) {
this.productionError = (data && data.detail) || '{{ _("Errore di comunicazione con il server") }}';
return null;
}
return data;
} catch (e) {
this.productionError = '{{ _("Errore di connessione") }}';
return null;
}
},
// ---- Measurement timer ----
/* Ticks locally for a smooth display, but the number it starts from always comes
from the server, and every page load resynchronises it.
It does not stop at zero: past the deadline it keeps counting the other way,
and the banner reads the delay off it. */
startCountdownFrom(seconds) {
this.stopMeasurementTimer();
this.timerRemaining = seconds;
this.timerActive = true;
// Landing on a page that is already late is the same event as going late
// while sitting on it; being back in time re-arms the announcement.
if (seconds <= 0) {
this.onMeasurementDue();
} else {
this.dueAnnounced = false;
this.redirectIn = 0;
}
var self = this;
this._timerInterval = setInterval(function () { self.onSecond(); }, 1000);
},
onSecond() {
const wasAnnounced = this.dueAnnounced;
this.timerRemaining--;
if (this.timerRemaining <= 0) this.onMeasurementDue();
if (wasAnnounced && this.redirectIn > 0) {
this.redirectIn--;
if (this.redirectIn === 0) this.goToMeasurementTask();
}
},
stopMeasurementTimer() {
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
this.timerActive = false;
},
/* The interval has run out. Said once, then acted on.
Someone already measuring is late, not lost: the banner turns red and they
are left alone. Taking them elsewhere mid-cycle would wipe quotes they can
see on the screen and gain nothing.
Otherwise a new cycle starts, and it starts at the first measurement task of
the recipe - re-arming wherever the operator happens to be standing would
skip whatever comes before it and still count as a full cycle. */
onMeasurementDue() {
if (this.dueAnnounced) return;
this.dueAnnounced = true;
window.ProductionClock.playBuzzer();
if (this.isMeasurementTaskOfRun && !this.cycleConfirmed) return;
if (this.task.id === this.measurementTaskIds[0]) {
this.resetForNewCycle();
return;
}
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
},
/* Clear the screen for another pass over the same quotes.
Local only: everything already measured is on the server, and the next
reading is saved beside it rather than replacing it. */
resetForNewCycle() {
this.cycleConfirmed = false;
this.showCompletionOverlay = false;
this.measurements = [];
this.currentIndex = 0;
this.currentValue = null;
this.errorMessage = '';
this.inputStartedAt = Date.now();
},
// ---- Where the measurement lives ----
get isMeasurementTaskOfRun() {
return this.measurementTaskIds.indexOf(this.task.id) !== -1;
},
get measurementTaskUrl() {
return window.ProductionClock.measurementUrl(
this.productionRun, '{{ url_for("measure.task_execute", task_id=0) }}',
);
},
/* The next measurement task of the recipe, or null if this is the last one -
which is the one that closes the cycle and restarts the interval. */
get nextMeasurementTaskId() {
const ids = this.measurementTaskIds;
const idx = ids.indexOf(this.task.id);
if (idx === -1 || idx >= ids.length - 1) return null;
return ids[idx + 1];
},
get isOverdue() {
return this.timerActive && this.timerRemaining <= 0;
},
get announcedRedirect() {
return this.dueAnnounced && this.redirectIn > 0;
},
goToMeasurementTask() {
const url = this.measurementTaskUrl;
if (url) window.location.href = url;
},
goToTask(taskId) {
window.location.href =
'{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskId);
},
// ---- Avvio Produzione ----
/* Opens a production run on the server. That row is what makes the timer, the
cycle count and the history survive a change of task.
The hand-off to the ERP (GAIA) plugs in on top of this, once the protocol is
agreed: everything before it works without waiting for that. */
async startProduction() {
const run = await this.postProduction(
'{{ url_for("measure.api_start_production") }}',
{
recipe_id: this.task.recipe_id,
version_id: this.task.version_id,
lot_number: this.lotNumber || null,
serial_number: this.serialNumber || null,
},
);
if (run) {
this.adoptProductionRun(run);
return true;
}
return false;
},
// Is the current task the last one in the recipe sequence?
get isLastTask() {
const taskIds = window.__allTaskIds || [];
const idx = taskIds.indexOf(this.task.id);
return idx === -1 || idx >= taskIds.length - 1;
},
// Start production from the completion overlay (last START task) and begin
// the measurement-interval cycle if the recipe defines one.
async startProductionFromOverlay() {
this.showCompletionOverlay = false;
const opened = await this.startProduction();
if (!opened) {
// Server refused: keep the operator working rather than stranding them
// mid-shift, but productionError is on screen so it is not silent.
this.cycleConfirmed = true;
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
}
return;
}
// The START-phase measurements have just been taken: record them as the first
// cycle, which is also what starts the interval running.
await this.confirmCycle();
},
/* mm:ss of the distance from the deadline, either side of it. The direction is
carried by the wording in the banner, not by a minus sign. */
get timerDisplay() {
return window.ProductionClock.format(this.timerRemaining);
},
// ---- Navigate to next task (Completato) ----
/* With a production open the operator stays in the measurement loop until the
capoturno closes it: after the last measurement task they go back to the
first, not out to the summary. Before production starts, and on the
documental tasks that come before the measurement, the plain sequence
applies - that is the run-up, not the loop. */
goToNextTask() {
if (this.blockedByTolerance) {
this.refuseWhileBlocked();
return;
}
if (this.productionStarted && this.isMeasurementTaskOfRun) {
const next = this.nextMeasurementTaskId || this.measurementTaskIds[0];
// A recipe with a single measurement task loops on the spot: reloading the
// same page to arrive at the same state would only cost a round trip.
if (next === this.task.id) {
this.resetForNewCycle();
return;
}
this.goToTask(next);
return;
}
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
this.goToTask(taskIds[currentIdx + 1]);
} else {
this.goToSummary();
}
},
// ---- Open/close supervisor modal (always reset fields) ----
openSupervisorModal(action) {
this.supervisorAction = action;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.showSupervisorModal = true;
},
closeSupervisorModal() {
this.showSupervisorModal = false;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
},
/* What kind of task this is, as declared in the recipe.
Deducing it from the quotes meant a measurement task whose quotes had not
been entered yet behaved as a note - the screen changed shape depending on
how finished the recipe was. Falls back to the old rule for tasks saved
before the type existed and not yet migrated. */
get isMeasureTask() {
const declared = this.task.task_type;
if (!declared) return this.subtasks.length > 0;
return declared === 'measure' || declared === 'camera_measure';
},
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
loud rather than silently rendering an empty screen. */
get isMeasureTaskWithoutQuotes() {
return this.isMeasureTask && this.subtasks.length === 0;
},
// ---- Is the production stopped? ----
get isPaused() {
return !!this.productionRun && this.productionRun.status === 'paused';
},
// ---- 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 === 'ripresa') return '{{ _("Ripresa della produzione") }}';
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
return '';
},
/* Route the modal's confirm to what the action actually does. Fermo linea,
ripresa and fine produzione act on the production run; the out-of-tolerance
one is recorded on the measurement itself. */
async submitSupervisor() {
if (this.supervisorAction === 'fermo_linea') return this.runSupervisedAction('pause');
if (this.supervisorAction === 'ripresa') return this.runSupervisedAction('resume');
if (this.supervisorAction === 'fine_produzione') return this.runSupervisedAction('close');
return this.authoriseOutOfTolerance();
},
/* The capoturno lets an out-of-tolerance value stand.
The credentials go to the endpoint that writes the approval onto that
measurement. It used to be a bare credential check whose answer was thrown
away: nothing recorded who had said yes, and nothing depended on it, so
closing the modal was enough to walk past a value out of tolerance. */
async authoriseOutOfTolerance() {
if (!this.pendingFail) {
this.closeSupervisorModal();
return;
}
this.supervisorError = '';
this.supervisorValidating = true;
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const url = '{{ url_for("measure.api_authorise_measurement", measurement_id=0) }}'
.replace('/0/', '/' + this.pendingFail.id + '/');
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
}),
});
const data = await resp.json().catch(() => null);
if (!resp.ok || (data && data.error)) {
this.supervisorError = (data && data.detail)
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
this.supervisorValidating = false;
return;
}
this.supervisorValidating = false;
this.closeSupervisorModal();
this.pendingFail = null;
if (this.completedCount >= this.totalSubtasks) {
this.showCompletionOverlay = true;
} else {
this.advanceToNext();
}
} catch (err) {
this.supervisorError = '{{ _("Errore di connessione") }}';
this.supervisorValidating = false;
}
},
/* Fermo linea, ripresa e fine produzione.
These used to open the modal and then do nothing. They now act on the
production run: the credentials go to the endpoint, which checks them and
records on the run's trace who authorised the stop or the closure. */
async runSupervisedAction(action) {
if (!this.productionRun) {
this.supervisorError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
this.supervisorError = '';
this.supervisorValidating = true;
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
const run = await this.postProduction(base, {
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
});
this.supervisorValidating = false;
if (!run) {
// postProduction already holds the reason; show it inside the modal so the
// capoturno sees why the action was refused, rather than a silent no-op.
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
this.productionError = '';
return;
}
this.closeSupervisorModal();
this.adoptProductionRun(run);
if (run.status === 'closed') {
this.goToSummary();
}
},
// ---- Go to summary ----
goToSummary() {
const recipeId = this.task.recipe_id || 0;
window.location.href = '{{ url_for("measure.task_complete", recipe_id=0) }}'.replace('/0', '/' + recipeId) +
'?version_id=' + encodeURIComponent(this.task.version_id || '');
},
};
}
</script>
{% endblock %}