98b1c5ae85
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>
1784 lines
81 KiB
HTML
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 %}
|