bcd807e57d
Aggiunge servizio SPC con calcoli Cp/Cpk/Pp/Ppk, carta di controllo (UCL/LCL), istogramma con curva normale. Router FastAPI con 5 endpoint statistics, blueprint Flask con proxy AJAX, dashboard interattiva Alpine.js + Plotly.js con filtri per ricetta/subtask/date, riepilogo pass/fail, gauge Cpk e i18n IT/EN completo. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
745 lines
32 KiB
HTML
745 lines
32 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
/* Annotation viewer container aspect ratio */
|
|
.annotation-container {
|
|
position: relative;
|
|
width: 100%;
|
|
min-height: 300px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 0.75rem;
|
|
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 indicator */
|
|
@keyframes markerPulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
|
|
50% { box-shadow: 0 0 0 8px rgba(37, 99, 235, 0); }
|
|
}
|
|
.marker-active-pulse {
|
|
animation: markerPulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
/* Smooth value transition */
|
|
.value-enter {
|
|
animation: valueSlideIn 0.3s ease-out;
|
|
}
|
|
@keyframes valueSlideIn {
|
|
from { opacity: 0; transform: translateY(-8px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col"
|
|
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 — Task info + traceability
|
|
================================================================ #}
|
|
<div class="bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
|
|
{# Left: Task title and directive #}
|
|
<div class="flex-1 min-w-0">
|
|
{# Breadcrumb line #}
|
|
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
|
|
<a href="{{ url_for('measure.task_list', recipe_id=task.version_id or task.recipe_id or 0) }}"
|
|
class="text-xs text-[var(--text-muted)] hover:text-primary transition-colors inline-flex items-center 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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
|
</svg>
|
|
{{ _('Task') }}
|
|
</a>
|
|
<svg class="w-3 h-3 text-[var(--text-muted)]" 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>
|
|
<span class="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>
|
|
</div>
|
|
|
|
<h1 class="text-xl font-bold text-[var(--text-primary)] truncate">
|
|
{{ task.title or _('Task di misurazione') }}
|
|
</h1>
|
|
|
|
{% if task.directive or task.description %}
|
|
<p class="text-sm text-[var(--text-secondary)] mt-0.5 line-clamp-2">
|
|
{{ task.directive or task.description }}
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Right: Traceability + Caliper #}
|
|
<div class="flex items-center gap-3 flex-wrap shrink-0">
|
|
{# Lot badge #}
|
|
{% if lot_number %}
|
|
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg 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.5 h-3.5 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 %}
|
|
|
|
{# Serial badge #}
|
|
{% if serial_number %}
|
|
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg 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.5 h-3.5 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>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
MAIN CONTENT — Split layout
|
|
================================================================ #}
|
|
<div class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-5">
|
|
<div class="flex flex-col lg:flex-row gap-5 h-full">
|
|
|
|
{# ──────────────────────────────────────────────
|
|
LEFT COLUMN — Annotation Viewer (60%)
|
|
────────────────────────────────────────────── #}
|
|
<div class="lg:w-[60%] flex flex-col gap-4">
|
|
|
|
{# Image viewer card #}
|
|
<div class="tmf-card flex-1 flex flex-col overflow-hidden">
|
|
<div class="tmf-card-header flex items-center justify-between">
|
|
<span class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="1.75" 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>
|
|
{{ _('Disegno Tecnico') }}
|
|
</span>
|
|
<span class="text-xs text-[var(--text-muted)]"
|
|
x-text="'Marker #' + (currentSubtask?.marker_number || '-') + ' {{ _('attivo') }}'"></span>
|
|
</div>
|
|
|
|
<div class="tmf-card-body flex-1 p-0">
|
|
{% if task.file_path and task.file_type == 'image' %}
|
|
{# Image with annotation overlay #}
|
|
<div class="annotation-container"
|
|
x-data="annotationViewer()"
|
|
x-init="
|
|
imageUrl = '{{ url_for('static', filename='uploads/' ~ task.file_path) }}';
|
|
annotations = {{ task.annotations_json|default('null')|tojson }};
|
|
$nextTick(() => init());
|
|
"
|
|
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
|
|
<canvas x-ref="annotationCanvas"
|
|
@click="handleClick($event)"
|
|
class="cursor-pointer"></canvas>
|
|
</div>
|
|
|
|
{% elif task.file_path and task.file_type == 'pdf' %}
|
|
{# PDF placeholder #}
|
|
<div class="annotation-container flex items-center justify-center">
|
|
<div class="text-center py-12">
|
|
<svg class="w-16 h-16 mx-auto text-red-400 mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
</svg>
|
|
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Visualizzatore PDF') }}</p>
|
|
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('In fase di sviluppo') }}</p>
|
|
<a href="{{ url_for('static', filename='uploads/' ~ task.file_path) }}"
|
|
target="_blank"
|
|
class="btn btn-secondary mt-3 text-xs">
|
|
<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 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
|
</svg>
|
|
{{ _('Apri PDF') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{% else %}
|
|
{# No file placeholder #}
|
|
<div class="annotation-container flex items-center justify-center">
|
|
<div class="text-center py-12">
|
|
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3" 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-secondary)]">{{ _('Nessuna immagine allegata') }}</p>
|
|
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Segui le istruzioni nella direttiva') }}</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# Marker quick-nav strip (horizontal scrollable) #}
|
|
<div class="flex gap-2 overflow-x-auto pb-1 -mb-1 scrollbar-thin" x-show="subtasks.length > 1">
|
|
<template x-for="(st, idx) in subtasks" :key="st.id">
|
|
<button @click="goToSubtask(idx)"
|
|
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all duration-200"
|
|
:class="idx === currentIndex
|
|
? 'bg-primary text-white border-primary shadow-md marker-active-pulse'
|
|
: isMeasured(st.id)
|
|
? 'bg-measure-pass/10 text-measure-pass border-measure-pass/30 hover:bg-measure-pass/20'
|
|
: 'bg-[var(--bg-card)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-primary/50 hover:text-primary'
|
|
">
|
|
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold"
|
|
:class="idx === currentIndex
|
|
? 'bg-white/20'
|
|
: isMeasured(st.id)
|
|
? 'bg-measure-pass/20'
|
|
: 'bg-[var(--bg-secondary)]'
|
|
"
|
|
x-text="st.marker_number"></span>
|
|
<span x-text="st.description" class="truncate max-w-[100px]"></span>
|
|
{# Check icon if measured #}
|
|
<svg x-show="isMeasured(st.id)" class="w-3.5 h-3.5 text-measure-pass 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>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
{# ──────────────────────────────────────────────
|
|
RIGHT COLUMN — Measurement Panel (40%)
|
|
────────────────────────────────────────────── #}
|
|
<div class="lg:w-[40%] flex flex-col gap-4">
|
|
|
|
{# ---- Subtask Info Card ---- #}
|
|
<div class="tmf-card" x-show="currentSubtask">
|
|
<div class="p-4 sm:p-5">
|
|
|
|
{# Marker header #}
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-2.5">
|
|
<span class="inline-flex items-center justify-center w-9 h-9 rounded-full
|
|
bg-primary text-white font-bold text-sm shadow-md marker-active-pulse"
|
|
x-text="currentSubtask?.marker_number"></span>
|
|
<div>
|
|
<h2 class="font-semibold text-[var(--text-primary)] text-base leading-tight"
|
|
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
|
|
<span class="text-xs text-[var(--text-muted)]"
|
|
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
{# Counter badge #}
|
|
<span class="badge badge-neutral font-mono text-xs">
|
|
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
|
|
</span>
|
|
</div>
|
|
|
|
{# Tolerance parameters table #}
|
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm mb-1">
|
|
{# Nominal #}
|
|
<div class="flex items-center justify-between col-span-2 py-1.5 px-3 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800">
|
|
<span class="text-xs font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
|
|
<span class="font-mono font-bold text-primary text-base"
|
|
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
|
|
{# UTL #}
|
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-red-50/50 dark:bg-red-900/10">
|
|
<span class="text-xs text-measure-fail font-medium">UTL</span>
|
|
<span class="font-mono text-xs font-semibold text-measure-fail"
|
|
x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
|
|
{# LTL #}
|
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-red-50/50 dark:bg-red-900/10">
|
|
<span class="text-xs text-measure-fail font-medium">LTL</span>
|
|
<span class="font-mono text-xs font-semibold text-measure-fail"
|
|
x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
|
|
{# UWL #}
|
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
|
<span class="text-xs text-measure-warning font-medium">UWL</span>
|
|
<span class="font-mono text-xs font-semibold text-measure-warning"
|
|
x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
|
|
{# LWL #}
|
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
|
<span class="text-xs text-measure-warning font-medium">LWL</span>
|
|
<span class="font-mono text-xs font-semibold text-measure-warning"
|
|
x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
{# Already measured indicator #}
|
|
<div x-show="isMeasured(currentSubtask?.id)"
|
|
class="mt-3 p-2.5 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
|
|
<div class="flex items-center gap-2 text-sm text-measure-pass font-medium">
|
|
<svg class="w-4 h-4" 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>{{ _('Misurazione registrata') }}:</span>
|
|
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
|
<span class="text-xs" x-text="currentSubtask?.unit || 'mm'"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ---- Tolerance Visualization Bar ---- #}
|
|
<div class="tmf-card" x-show="currentSubtask">
|
|
<div class="p-4">
|
|
<div class="tolerance-bar-bg h-8 rounded-full relative overflow-hidden border border-[var(--border-color)]">
|
|
{# Zone labels #}
|
|
<div class="absolute inset-0 flex items-center justify-between px-2 text-[9px] 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>
|
|
|
|
{# Nominal center line #}
|
|
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
|
|
|
|
{# Value indicator (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)'">
|
|
{# Pip on top #}
|
|
<div class="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white dark:border-slate-800 shadow-md"
|
|
:class="{
|
|
'bg-measure-pass': passFailStatus === 'pass',
|
|
'bg-measure-warning': passFailStatus === 'warning',
|
|
'bg-measure-fail': passFailStatus === 'fail'
|
|
}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ---- Measurement Feedback ---- #}
|
|
<div class="px-1" 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 ---- #}
|
|
<div class="tmf-card">
|
|
<div class="p-4 relative">
|
|
{# Saving overlay #}
|
|
<div x-show="saving"
|
|
x-transition
|
|
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 rounded-xl flex items-center justify-center backdrop-blur-sm">
|
|
<div class="flex items-center gap-3 text-primary font-medium">
|
|
<svg class="animate-spin w-5 h-5" 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>
|
|
</div>
|
|
|
|
{# ---- Next Measurement Indicator ---- #}
|
|
<div class="px-1">
|
|
{% include "components/next_measurement.html" %}
|
|
</div>
|
|
|
|
{# ---- Error message ---- #}
|
|
<div x-show="errorMessage"
|
|
x-transition
|
|
x-cloak
|
|
class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 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>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
FOOTER — Progress bar + navigation
|
|
================================================================ #}
|
|
<div class="sticky bottom-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-30">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
|
<div class="flex items-center gap-4">
|
|
|
|
{# Left: Back button #}
|
|
<a href="{{ url_for('measure.task_list', recipe_id=task.version_id or task.recipe_id or 0) }}"
|
|
class="btn btn-secondary text-xs shrink-0 gap-1.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 #}
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-3">
|
|
{# Text counter #}
|
|
<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>
|
|
|
|
{# Progress bar #}
|
|
<div class="flex-1 h-2.5 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>
|
|
|
|
{# Percentage #}
|
|
<span class="text-xs font-bold shrink-0"
|
|
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
|
|
x-text="Math.round(progressPercent) + '%'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
{# Right: Complete / next task button #}
|
|
<button x-show="isComplete"
|
|
x-transition
|
|
@click="goToSummary()"
|
|
class="btn btn-primary text-xs shrink-0 gap-1.5 shadow-md">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
{{ _('Riepilogo') }}
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</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">
|
|
|
|
{# Success icon #}
|
|
<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>
|
|
|
|
{# Summary counts #}
|
|
<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>
|
|
|
|
<button @click="goToSummary()"
|
|
class="btn btn-primary w-full justify-center gap-2">
|
|
{{ _('Vai al Riepilogo') }}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}"></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.
|
|
*/
|
|
function taskExecute() {
|
|
return {
|
|
// ---- Data from server ----
|
|
task: {{ task|tojson }},
|
|
subtasks: {{ task.subtasks|tojson if task.subtasks else '[]' }},
|
|
lotNumber: '{{ lot_number or '' }}',
|
|
serialNumber: '{{ serial_number or '' }}',
|
|
|
|
// ---- Measurement state ----
|
|
currentIndex: 0,
|
|
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
|
saving: false,
|
|
errorMessage: '',
|
|
showCompletionOverlay: false,
|
|
|
|
// ---- Value from numpad / caliper ----
|
|
currentValue: null,
|
|
|
|
// ---- 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() {
|
|
// Sort subtasks by order_index to ensure correct order
|
|
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
|
},
|
|
|
|
// ---- 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;
|
|
},
|
|
|
|
// ---- Handle numpad confirm ----
|
|
async handleMeasurement(value, inputMethod) {
|
|
if (!this.currentSubtask || this.saving) return;
|
|
|
|
this.currentValue = value;
|
|
this.errorMessage = '';
|
|
|
|
// Compute pass/fail before saving
|
|
const pf = this.passFailStatus;
|
|
const dev = this.deviation;
|
|
|
|
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,
|
|
task_id: this.task.id,
|
|
value: value,
|
|
pass_fail: pf,
|
|
deviation: dev,
|
|
lot_number: this.lotNumber,
|
|
serial_number: this.serialNumber,
|
|
input_method: inputMethod || 'manual',
|
|
}),
|
|
});
|
|
|
|
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
|
|
this.measurements.push({
|
|
subtask_id: this.currentSubtask.id,
|
|
value: value,
|
|
pass_fail: pf,
|
|
deviation: dev,
|
|
});
|
|
|
|
this.saving = false;
|
|
|
|
// Check if all done
|
|
if (this.completedCount >= this.totalSubtasks) {
|
|
// Show completion overlay
|
|
this.showCompletionOverlay = true;
|
|
return;
|
|
}
|
|
|
|
// Advance to next unmeasured subtask
|
|
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;
|
|
|
|
// First try the next sequential subtask
|
|
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
|
|
if (!this.isMeasured(this.subtasks[i].id)) {
|
|
this.currentIndex = i;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Wrap around from beginning
|
|
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 = '';
|
|
}
|
|
},
|
|
|
|
goToSubtaskByMarker(markerNumber) {
|
|
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
|
|
if (idx !== -1) {
|
|
this.goToSubtask(idx);
|
|
}
|
|
},
|
|
|
|
// ---- Go to summary ----
|
|
goToSummary() {
|
|
const recipeId = this.task.version_id || 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 %}
|