7bc3c1f938
Punto 6 del documento modifiche del 28/07. I due pulsanti esistevano, chiedevano correttamente l'autorizzazione del capoturno, e poi ricadevano su un commento: "handled by GAIA integration (future)". E' la riga di apertura del documento. Ora agiscono sulla produzione aperta: fermo linea sospende e congela il conto alla rovescia, la ripresa lo fa ripartire restituendo il tempo del fermo, fine produzione chiude ed emette il file di statistica. Le credenziali del capoturno vanno direttamente all'endpoint, che le verifica e registra sulla traccia della produzione chi ha autorizzato cosa: validarle a parte non lascerebbe traccia, e sarebbe un giro in piu'. In interfaccia il pulsante di fermo diventa "Riprendi" quando la linea e' ferma, e una banda dichiara lo stato con il valore congelato a video: una linea ferma che sembra in marcia e' il modo in cui si salta un intervallo di misura senza accorgersene. Il file di statistica ha richiesto di legare le misure alla produzione (migrazione 006): Measurement conosceva solo versione, lotto e seriale, nessuno dei quali separa una produzione dalla successiva sulla stessa ricetta e sullo stesso lotto, quindi "le misure dell'intera produzione" non era una query. Il CSV rispetta i separatori configurati in system_settings e porta con se' i limiti di tolleranza: senza quelli un esito pass/fail non e' piu' ricalcolabile dal file a distanza di anni, che e' il senso di un documento per audit. Le misure esportate vengono marcate synced_to_csv. Se la produzione non ha misure non viene scritto nulla: un file vuoto sarebbe rumore nella cartella, non evidenza. L'invio a GAIA resta assente e non abbozzato, con il punto d'innesto dichiarato in close_run: una chiamata vuota che sembra collegata e' peggio di niente. Tutto il resto del punto 6 non dipende dal gestionale e funziona adesso. Anche la 006 e' stata eseguita su SQLite usa e getta prima di essere considerata buona, e anche qui la prova ha trovato un difetto: aggiungere una colonna con foreign key fa emettere ad alembic un ALTER di vincolo, che SQLite rifiuta. Ora usa batch_alter_table con il vincolo nominato, che su MySQL resta un ALTER normale e su SQLite ricostruisce la tabella - cosi' la chiave esterna non va persa per far contento il dialetto dei test. Corretto anche il proxy di salvataggio misura, che chiamava /api/measurements senza slash finale e pagava un redirect 307 a ogni singola misura. Traduzioni: pybabel aveva di nuovo indovinato sette voci marcandole fuzzy, e in italiano "Linea ferma" era diventato "Lingua Preferita" su una banda di sicurezza. Tradotte per esteso in IT ed EN e tolti i flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1352 lines
60 KiB
HTML
1352 lines
60 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)"
|
|
@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 + Riepilogo buttons #}
|
|
<div class="shrink-0 flex items-center gap-1.5">
|
|
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
|
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
|
</svg>
|
|
{{ _('Lista task') }}
|
|
</a>
|
|
<button @click="goToSummary()"
|
|
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
{{ _('Riepilogo') }}
|
|
</button>
|
|
</div>
|
|
|
|
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
|
<template x-if="subtasks.length > 0">
|
|
<div class="shrink-0 flex items-center gap-1.5">
|
|
{# 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="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)]">
|
|
|
|
{# 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="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
|
|
================================================================ #}
|
|
<div x-show="timerActive"
|
|
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-3">
|
|
<svg class="w-5 h-5 text-amber-600 animate-pulse" 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 class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
|
{{ _('Prossima misurazione tra') }}
|
|
</span>
|
|
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
|
|
x-text="timerDisplay"></span>
|
|
<span class="text-xs text-amber-600 dark:text-amber-400">
|
|
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
|
|
</span>
|
|
</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>
|
|
<span class="text-xs text-amber-800 dark:text-amber-200">
|
|
{{ _('Il conto alla rovescia è congelato a') }}
|
|
<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 && subtasks.length > 0"
|
|
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 && subtasks.length > 0"
|
|
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) / Completato (non-measurement or after cycle) #}
|
|
<template x-if="subtasks.length > 0">
|
|
<div class="flex items-center gap-1.5">
|
|
<button x-show="isComplete && !cycleConfirmed"
|
|
x-transition
|
|
@click="confirmCycle()"
|
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
|
bg-primary text-white hover:bg-primary-700">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
{{ _('Fine ciclo misura') }}
|
|
</button>
|
|
<button x-show="cycleConfirmed"
|
|
x-transition
|
|
@click="goToNextTask()"
|
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
|
bg-measure-pass text-white hover:opacity-90">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
{{ _('Completato') }}
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="subtasks.length === 0">
|
|
<button @click="goToNextTask()"
|
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
|
bg-measure-pass text-white hover:opacity-90">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
{{ _('Completato') }}
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
</svg>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
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 (starts the interval timer) #}
|
|
<button x-show="productionStarted"
|
|
@click="showCompletionOverlay = false; cycleConfirmed = true"
|
|
class="btn btn-primary flex-1 justify-center gap-2">
|
|
{{ _('Conferma ciclo') }}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{# 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>
|
|
</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">
|
|
<button @click="closeSupervisorModal()"
|
|
class="btn btn-secondary flex-1 text-sm">
|
|
{{ _('Annulla') }}
|
|
</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>
|
|
|
|
<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: '',
|
|
|
|
// ---- Cycle & workflow state ----
|
|
cycleConfirmed: false,
|
|
showSupervisorModal: false,
|
|
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
|
supervisorUsername: '',
|
|
supervisorPassword: '',
|
|
supervisorError: '',
|
|
supervisorValidating: false,
|
|
pendingAdvance: false,
|
|
|
|
// ---- Value from numpad / caliper ----
|
|
currentValue: null,
|
|
|
|
// ---- 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;
|
|
},
|
|
|
|
// ---- 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 ----
|
|
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.
|
|
this.loadProductionRun();
|
|
},
|
|
|
|
// ---- 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;
|
|
|
|
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;
|
|
},
|
|
|
|
// ---- 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: block advancement, require supervisor
|
|
if (pf === 'fail') {
|
|
this.pendingAdvance = true;
|
|
this.openSupervisorModal('out_of_tolerance');
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
},
|
|
|
|
// ---- Navigation ----
|
|
goToSubtask(index) {
|
|
if (index >= 0 && index < this.totalSubtasks) {
|
|
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) ----
|
|
async confirmCycle() {
|
|
this.cycleConfirmed = true;
|
|
this.showCompletionOverlay = false;
|
|
|
|
// Recorded server-side so the count and the next deadline outlive this page.
|
|
if (this.productionRun) {
|
|
const run = await this.postProduction(
|
|
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
|
);
|
|
if (run) {
|
|
this.adoptProductionRun(run);
|
|
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);
|
|
}
|
|
},
|
|
|
|
/* 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. */
|
|
startCountdownFrom(seconds) {
|
|
this.stopMeasurementTimer();
|
|
this.timerRemaining = seconds;
|
|
this.timerActive = true;
|
|
var self = this;
|
|
this._timerInterval = setInterval(function () {
|
|
self.timerRemaining--;
|
|
if (self.timerRemaining <= 0) {
|
|
self.onTimerExpired();
|
|
}
|
|
}, 1000);
|
|
},
|
|
|
|
stopMeasurementTimer() {
|
|
if (this._timerInterval) {
|
|
clearInterval(this._timerInterval);
|
|
this._timerInterval = null;
|
|
}
|
|
this.timerActive = false;
|
|
},
|
|
|
|
onTimerExpired() {
|
|
this.stopMeasurementTimer();
|
|
this.playBuzzer();
|
|
// Reset for new measurement cycle
|
|
this.cycleConfirmed = false;
|
|
this.measurements = [];
|
|
this.currentIndex = 0;
|
|
this.currentValue = null;
|
|
},
|
|
|
|
playBuzzer() {
|
|
try {
|
|
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
// 3 short beeps
|
|
[0, 0.25, 0.5].forEach(function (delay) {
|
|
var osc = ctx.createOscillator();
|
|
var gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.frequency.value = 880;
|
|
osc.type = 'square';
|
|
gain.gain.value = 0.3;
|
|
osc.start(ctx.currentTime + delay);
|
|
osc.stop(ctx.currentTime + delay + 0.15);
|
|
});
|
|
} catch (_) {}
|
|
},
|
|
|
|
// ---- 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();
|
|
},
|
|
|
|
get timerDisplay() {
|
|
var m = Math.floor(this.timerRemaining / 60);
|
|
var s = this.timerRemaining % 60;
|
|
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
|
},
|
|
|
|
// ---- Navigate to next task (Completato) ----
|
|
goToNextTask() {
|
|
const taskIds = window.__allTaskIds || [];
|
|
const currentIdx = taskIds.indexOf(this.task.id);
|
|
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
|
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
|
} else {
|
|
this.goToSummary();
|
|
}
|
|
},
|
|
|
|
// ---- 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 = '';
|
|
},
|
|
|
|
// ---- 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
|
|
gate stays a pure credential check. */
|
|
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.validateSupervisor();
|
|
},
|
|
|
|
// ---- Validate supervisor credentials ----
|
|
async validateSupervisor() {
|
|
this.supervisorError = '';
|
|
this.supervisorValidating = true;
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
|
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
|
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
|
|
});
|
|
const data = await resp.json();
|
|
|
|
if (!resp.ok || data.error) {
|
|
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
|
this.supervisorValidating = false;
|
|
return;
|
|
}
|
|
|
|
// Authorized — close modal and proceed
|
|
this.showSupervisorModal = false;
|
|
this.supervisorUsername = '';
|
|
this.supervisorPassword = '';
|
|
this.supervisorValidating = false;
|
|
|
|
if (this.supervisorAction === 'out_of_tolerance') {
|
|
this.pendingAdvance = false;
|
|
if (this.completedCount >= this.totalSubtasks) {
|
|
this.showCompletionOverlay = true;
|
|
} else {
|
|
this.advanceToNext();
|
|
}
|
|
}
|
|
|
|
} 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 %}
|