feat: FASE 3 - Flusso MeasurementTec (selezione ricetta, esecuzione misure, riepilogo)
Implementazione completa del flusso operativo per il ruolo MeasurementTec:
Blueprint measure.py:
- select_recipe: selezione ricetta con ricerca e barcode
- task_list: lista task con conteggi subtask e allegati
- task_execute: esecuzione misure con numpad, calibro USB, feedback real-time
- task_complete: riepilogo con statistiche pass/fail e export CSV
- API AJAX: lookup-barcode, save-traceability, save-measurement
- Autorizzazione role_required("MeasurementTec") su tutte le route
Componenti riutilizzabili:
- numpad.html/js/css: tastierino numerico touch-friendly con keyboard support
- caliper_status.html + caliper.js: integrazione calibro USB via Web Serial API
- barcode_scanner.html + barcode.js: scansione barcode con html5-qrcode
- measurement_feedback.html: feedback visivo pass/warning/fail in tempo reale
- next_measurement.html: indicatore prossima misurazione
- annotation-viewer.js: visualizzatore canvas con marker su disegni tecnici
- csv-export.js: export CSV con locale italiano (delimitatore ;, decimale ,)
Sicurezza:
- Decoratore role_required(*roles) per autorizzazione basata su ruoli
- CSRF token su tutti i POST AJAX
- |tojson per prevenire XSS su annotations_json
- Validazione input lato client e server
i18n: 23+ nuove chiavi tradotte IT/EN per tutti i template FASE 3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Seleziona Ricetta') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
|
||||
x-data="{
|
||||
recipes: {{ recipes|tojson }},
|
||||
search: '{{ auto_recipe_code }}',
|
||||
lot_number: '{{ auto_lot }}',
|
||||
serial_number: '{{ auto_serial }}',
|
||||
barcodeModal: false,
|
||||
barcodeInput: '',
|
||||
barcodeLoading: false,
|
||||
barcodeError: '',
|
||||
get filteredRecipes() {
|
||||
if (!this.search) return this.recipes;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.recipes.filter(r =>
|
||||
(r.name || '').toLowerCase().includes(q) ||
|
||||
(r.code || '').toLowerCase().includes(q) ||
|
||||
(r.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
buildTaskUrl(recipeId) {
|
||||
let url = '/measure/tasks/' + recipeId + '?';
|
||||
const params = [];
|
||||
if (this.lot_number) params.push('lot_number=' + encodeURIComponent(this.lot_number));
|
||||
if (this.serial_number) params.push('serial_number=' + encodeURIComponent(this.serial_number));
|
||||
return url + params.join('&');
|
||||
},
|
||||
async lookupBarcode() {
|
||||
if (!this.barcodeInput.trim()) return;
|
||||
this.barcodeLoading = true;
|
||||
this.barcodeError = '';
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
const resp = await fetch('/measure/lookup-barcode', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify({ code: this.barcodeInput.trim() })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}';
|
||||
} else {
|
||||
this.barcodeModal = false;
|
||||
this.barcodeInput = '';
|
||||
window.location.href = this.buildTaskUrl(data.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.barcodeError = '{{ _("Errore di connessione") }}';
|
||||
} finally {
|
||||
this.barcodeLoading = false;
|
||||
}
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="h-7 w-7 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-[var(--text-primary)]">
|
||||
{{ _('Seleziona Ricetta') }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Scegli la ricetta di misura da eseguire') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barcode Button -->
|
||||
<button @click="barcodeModal = true"
|
||||
class="btn btn-secondary gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4h2v16H3V4zm4 0h1v16H7V4zm3 0h2v16h-2V4zm4 0h3v16h-3V4zm5 0h2v16h-2V4z"/>
|
||||
</svg>
|
||||
{{ _('Scansiona Barcode') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Traceability Bar -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">
|
||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{{ _('Cerca ricetta') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
|
||||
<!-- Lot Number -->
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
|
||||
<input type="text"
|
||||
x-model="lot_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
|
||||
<!-- Serial Number -->
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
|
||||
<input type="text"
|
||||
x-model="serial_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Count -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
<span x-text="filteredRecipes.length"></span> {{ _('ricette trovate') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<template x-for="recipe in filteredRecipes" :key="recipe.id">
|
||||
<div class="tmf-card flex flex-col hover:border-primary/30 transition-all duration-200 group">
|
||||
<!-- Card Header -->
|
||||
<div class="p-5 flex-1">
|
||||
<!-- Code Badge + Version -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold
|
||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||
border border-primary-200 dark:border-primary-800 font-mono tracking-wide"
|
||||
x-text="recipe.code">
|
||||
</span>
|
||||
<span class="badge badge-neutral text-xs"
|
||||
x-show="recipe.current_version || recipe.version"
|
||||
x-text="'v' + (recipe.current_version || recipe.version || '')">
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Name -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug"
|
||||
x-text="recipe.name">
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||
</p>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||
<template x-if="recipe.task_count != null">
|
||||
<span class="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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
<span x-text="recipe.task_count + ' task'"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="recipe.updated_at">
|
||||
<span class="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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span x-text="new Date(recipe.updated_at).toLocaleDateString('it-IT')"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Footer -->
|
||||
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
|
||||
rounded-b-xl">
|
||||
<a :href="buildTaskUrl(recipe.id)"
|
||||
class="btn btn-primary w-full justify-center text-sm font-semibold
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Seleziona') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="filteredRecipes.length === 0" x-cloak
|
||||
class="text-center py-16">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||
bg-[var(--bg-secondary)] mb-4">
|
||||
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
{{ _('Nessuna ricetta trovata') }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
|
||||
<span x-show="search">
|
||||
{{ _('Nessun risultato per') }} "<span x-text="search" class="font-medium"></span>".
|
||||
{{ _('Prova con un termine diverso.') }}
|
||||
</span>
|
||||
<span x-show="!search">
|
||||
{{ _('Non ci sono ricette disponibili al momento.') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Barcode Modal -->
|
||||
<div x-show="barcodeModal" x-cloak
|
||||
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"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="barcodeModal = false">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50" @click="barcodeModal = false"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative w-full max-w-md bg-[var(--bg-card)] rounded-xl shadow-2xl
|
||||
border border-[var(--border-color)]"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.stop>
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-5 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4h2v16H3V4zm4 0h1v16H7V4zm3 0h2v16h-2V4zm4 0h3v16h-3V4zm5 0h2v16h-2V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{{ _('Scansiona Barcode') }}
|
||||
</h3>
|
||||
</div>
|
||||
<button @click="barcodeModal = false"
|
||||
class="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-5">
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Inserisci o scansiona il codice della ricetta per selezionarla automaticamente.') }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Codice Ricetta') }}</label>
|
||||
<input type="text"
|
||||
x-model="barcodeInput"
|
||||
@keydown.enter.prevent="lookupBarcode()"
|
||||
x-ref="barcodeField"
|
||||
x-init="$watch('barcodeModal', val => { if (val) setTimeout(() => $refs.barcodeField.focus(), 100) })"
|
||||
placeholder="{{ _('Es. REC-001') }}"
|
||||
class="tmf-input font-mono text-lg tracking-wider"
|
||||
:disabled="barcodeLoading">
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="barcodeError" x-cloak
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20
|
||||
border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm">
|
||||
<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="barcodeError"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center justify-end gap-3 p-5 border-t border-[var(--border-color)]
|
||||
bg-[var(--bg-secondary)] rounded-b-xl">
|
||||
<button @click="barcodeModal = false; barcodeInput = ''; barcodeError = ''"
|
||||
class="btn btn-secondary">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="lookupBarcode()"
|
||||
:disabled="!barcodeInput.trim() || barcodeLoading"
|
||||
class="btn btn-primary">
|
||||
<svg x-show="barcodeLoading" 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"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span x-show="!barcodeLoading">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
{{ _('Cerca') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,275 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Riepilogo') }} - {{ recipe.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<li>
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="hover:text-primary transition-colors inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 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>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=recipe.id) }}"
|
||||
class="hover:text-primary transition-colors">
|
||||
{{ recipe.name }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 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>
|
||||
</li>
|
||||
<li class="font-medium text-[var(--text-primary)]">
|
||||
{{ _('Riepilogo') }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Recipe Info Card -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="tmf-card-header">
|
||||
<h1 class="text-2xl font-bold" style="color: var(--text-primary);">{{ _('Misurazioni Complete') }}</h1>
|
||||
</div>
|
||||
<div class="tmf-card-body">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Codice') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ recipe.code }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Nome') }}</p>
|
||||
<p class="font-medium" style="color: var(--text-primary);">{{ recipe.name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Versione') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ recipe.current_version or recipe.version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Lotto') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ lot_number or '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if serial_number %}
|
||||
<div class="mt-3 pt-3" style="border-top: 1px solid var(--border-color);">
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Seriale') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ serial_number }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
{% set total_count = measurements|length %}
|
||||
{% set pass_count = measurements|selectattr('pass_fail', 'equalto', 'pass')|list|length %}
|
||||
{% set warning_count = measurements|selectattr('pass_fail', 'equalto', 'warning')|list|length %}
|
||||
{% set fail_count = measurements|selectattr('pass_fail', 'equalto', 'fail')|list|length %}
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Totale') }}</p>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary);">{{ total_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<svg class="h-8 w-8" style="color: var(--text-primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pass -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Conformi') }}</p>
|
||||
<p class="text-3xl font-bold text-measure-pass dark:text-green-400">{{ pass_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="h-8 w-8 text-measure-pass dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Attenzione') }}</p>
|
||||
<p class="text-3xl font-bold text-measure-warning dark:text-amber-400">{{ warning_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="h-8 w-8 text-measure-warning dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fail -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Non Conformi') }}</p>
|
||||
<p class="text-3xl font-bold text-measure-fail dark:text-red-400">{{ fail_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-8 w-8 text-measure-fail dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurements Table -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="tmf-card-header flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary);">{{ _('Dettaglio Misurazioni') }}</h2>
|
||||
<button
|
||||
onclick="(function(){ var e = csvExport(); e.exportMeasurements(window.measurementData.measurements, 'misure_' + window.measurementData.recipeCode + '.csv'); })()"
|
||||
class="btn btn-secondary flex items-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
<span>{{ _('Esporta CSV') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tmf-card-body overflow-x-auto">
|
||||
<table class="tmf-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center">#</th>
|
||||
<th>{{ _('Descrizione') }}</th>
|
||||
<th class="text-right">{{ _('Nominale') }}</th>
|
||||
<th class="text-right">{{ _('Valore') }}</th>
|
||||
<th class="text-right">{{ _('Deviazione') }}</th>
|
||||
<th class="text-center">{{ _('Esito') }}</th>
|
||||
<th class="text-center">{{ _('Metodo') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in measurements|sort(attribute='subtask.marker_number') %}
|
||||
<tr>
|
||||
<td class="text-center font-mono font-medium" style="color: var(--text-primary);">
|
||||
{{ m.subtask.marker_number }}
|
||||
</td>
|
||||
<td style="color: var(--text-primary);">
|
||||
{{ m.subtask.description }}
|
||||
</td>
|
||||
<td class="text-right measure-value">
|
||||
{{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }}
|
||||
</td>
|
||||
<td class="text-right measure-value font-semibold" style="color: var(--text-primary);">
|
||||
{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}
|
||||
</td>
|
||||
<td class="text-right measure-value {% if m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
|
||||
{{ "%+.3f"|format(m.deviation) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if m.pass_fail == 'pass' %}
|
||||
<span class="badge badge-pass">{{ _('Pass') }}</span>
|
||||
{% elif m.pass_fail == 'warning' %}
|
||||
<span class="badge badge-warning">{{ _('Warning') }}</span>
|
||||
{% elif m.pass_fail == 'fail' %}
|
||||
<span class="badge badge-fail">{{ _('Fail') }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-neutral">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if m.method == 'manual' %}
|
||||
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
<span class="text-sm">{{ _('Manuale') }}</span>
|
||||
</div>
|
||||
{% elif m.method == 'caliper' %}
|
||||
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
|
||||
</svg>
|
||||
<span class="text-sm">{{ _('Calibro') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm" style="color: var(--text-muted);">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-between items-center">
|
||||
<div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="btn btn-secondary w-full sm:w-auto flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
|
||||
</svg>
|
||||
<span>{{ _('Seleziona altra ricetta') }}</span>
|
||||
</a>
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=recipe.id) }}"
|
||||
class="btn btn-secondary w-full sm:w-auto flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
<span>{{ _('Ripeti misurazioni') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.get('roles', []) %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="btn btn-primary w-full sm:w-auto flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<span>{{ _('Statistiche') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/csv-export.js') }}"></script>
|
||||
<script>
|
||||
// Pass data to csv-export.js
|
||||
window.measurementData = {
|
||||
recipeName: {{ recipe.name|tojson }},
|
||||
recipeCode: {{ recipe.code|tojson }},
|
||||
version: {{ (recipe.current_version or recipe.version)|tojson }},
|
||||
lotNumber: {{ (lot_number or '')|tojson }},
|
||||
serialNumber: {{ (serial_number or '')|tojson }},
|
||||
measurements: {{ measurements|tojson }}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,750 @@
|
||||
{% 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)"
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
|
||||
@caliper-reading.window="handleCaliperReading($event.detail.value)">
|
||||
|
||||
{# ================================================================
|
||||
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) {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Caliper reading ----
|
||||
handleCaliperReading(value) {
|
||||
// Auto-fill value from USB caliper
|
||||
this.currentValue = value;
|
||||
},
|
||||
|
||||
// ---- 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 %}
|
||||
@@ -0,0 +1,247 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<li>
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="hover:text-primary transition-colors inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 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>
|
||||
</li>
|
||||
<li class="font-medium text-[var(--text-primary)]">
|
||||
{{ recipe.name }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Recipe Info Card -->
|
||||
<div class="tmf-card mb-8">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<!-- Left: Recipe Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<!-- Code Badge -->
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-semibold
|
||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||
border border-primary-200 dark:border-primary-800 font-mono tracking-wide">
|
||||
{{ recipe.code }}
|
||||
</span>
|
||||
|
||||
<!-- Version Badge -->
|
||||
{% if recipe.current_version or recipe.version %}
|
||||
<span class="badge badge-neutral">
|
||||
v{{ recipe.current_version or recipe.version }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-2">
|
||||
{{ recipe.name }}
|
||||
</h1>
|
||||
|
||||
{% if recipe.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed max-w-2xl">
|
||||
{{ recipe.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Traceability Badges -->
|
||||
<div class="flex flex-col gap-2 sm:items-end shrink-0">
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
text-amber-800 dark:text-amber-200">
|
||||
<svg class="w-4 h-4 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-medium">{{ _('Lotto') }}:</span>
|
||||
<span class="font-mono font-semibold">{{ lot_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if serial_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
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-4 h-4 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-medium">{{ _('Seriale') }}:</span>
|
||||
<span class="font-mono font-semibold">{{ serial_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Count Header -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svg class="w-5 h-5 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 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
{{ _('Task da eseguire') }}
|
||||
</h2>
|
||||
<span class="badge badge-neutral">
|
||||
{{ tasks|length }} task
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Cards -->
|
||||
{% if tasks %}
|
||||
<div class="space-y-4">
|
||||
{% for task in tasks %}
|
||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
|
||||
<!-- Task Number Circle -->
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0
|
||||
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
|
||||
text-primary-700 dark:text-primary-300 font-bold text-lg">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
|
||||
<!-- Task Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Task Header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Title -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
|
||||
</h3>
|
||||
|
||||
<!-- Directive -->
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
|
||||
{{ task.directive or task.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Meta Row -->
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<!-- Subtask Count -->
|
||||
{% if task.subtask_count is defined or task.subtasks %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{% if task.subtask_count is defined %}
|
||||
{{ task.subtask_count }}
|
||||
{% elif task.subtasks %}
|
||||
{{ task.subtasks|length }}
|
||||
{% endif %}
|
||||
{{ _('misurazioni') }}
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Attachment -->
|
||||
{% if task.file_path %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
{% if task.file_path.endswith('.pdf') %}
|
||||
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" 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>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-green-500" 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>
|
||||
{% endif %}
|
||||
<span class="font-medium">{{ _('Allegato') }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<div class="shrink-0 sm:ml-4">
|
||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
<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.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Inizia Misure') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||
bg-[var(--bg-secondary)] mb-4">
|
||||
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
{{ _('Nessun task disponibile') }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
|
||||
{{ _('Questa ricetta non ha ancora task definiti.') }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="btn btn-secondary gap-2">
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
{{ _('Seleziona altra ricetta') }}
|
||||
</a>
|
||||
|
||||
{% if tasks %}
|
||||
<div class="text-sm text-[var(--text-secondary)]">
|
||||
{{ tasks|length }} task ·
|
||||
{% set total_subs = namespace(count=0) %}
|
||||
{% for t in tasks %}
|
||||
{% if t.subtask_count is defined %}
|
||||
{% set total_subs.count = total_subs.count + t.subtask_count %}
|
||||
{% elif t.subtasks %}
|
||||
{% set total_subs.count = total_subs.count + t.subtasks|length %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if total_subs.count > 0 %}
|
||||
{{ total_subs.count }} {{ _('misurazioni totali') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user