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:
Adriano
2026-02-07 08:40:58 +01:00
parent edd4580a5a
commit a386986c17
19 changed files with 3905 additions and 16 deletions
@@ -0,0 +1,151 @@
<!--
Barcode Scanner Modal Component
Camera-based barcode/QR code scanner
Include in pages where barcode scanning is needed
-->
<div x-data="barcodeScanner()"
@barcode-use.window="stopScan(); clear()"
x-init="$watch('scanning', val => console.log('Scanning:', val))"
x-cloak>
<!-- Trigger button (styled slot - customize per use case) -->
<slot name="trigger">
<button @click="startScan()"
type="button"
class="inline-flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors duration-200 shadow-sm hover:shadow-md">
<!-- Barcode icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
</svg>
<span>{{ _('Scansiona Barcode') }}</span>
</button>
</slot>
<!-- Modal overlay -->
<div x-show="scanning || result"
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 bg-black/60 backdrop-blur-sm p-4"
style="display: none;">
<div @click.outside="scanning && !result ? null : (stopScan(), clear())"
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"
class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full overflow-hidden">
<!-- Header -->
<div class="flex justify-between items-center px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
{{ _('Scansiona Barcode') }}
</h3>
</div>
<button @click="stopScan(); clear()"
type="button"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="p-6">
<!-- Camera preview container -->
<div x-show="scanning && !result"
class="relative rounded-xl overflow-hidden bg-slate-900 aspect-square mb-4">
<!-- Scanner element -->
<div id="barcode-reader" class="w-full h-full"></div>
<!-- Scanning indicator -->
<div class="absolute inset-0 pointer-events-none">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-64 h-64 border-2 border-white/50 rounded-lg"></div>
</div>
</div>
<!-- Instruction overlay -->
<div class="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<p class="text-white text-sm text-center font-medium">
{{ _('Inquadra il codice a barre nel riquadro') }}
</p>
</div>
</div>
<!-- Success result -->
<div x-show="result"
x-transition
class="text-center py-8">
<!-- Success icon -->
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-3">
{{ _('Codice rilevato') }}
</p>
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-lg p-4 mb-4">
<p class="font-mono text-xl font-bold text-primary break-all" x-text="result"></p>
</div>
</div>
<!-- Error message -->
<div x-show="error"
x-transition
class="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
</div>
</div>
<!-- Action buttons -->
<div class="flex gap-3">
<button x-show="result"
@click="$dispatch('barcode-use', { code: result }); stopScan(); clear()"
type="button"
class="flex-1 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md">
{{ _('Usa Codice') }}
</button>
<button x-show="scanning && !result"
@click="stopScan()"
type="button"
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
{{ _('Ferma') }}
</button>
<button x-show="!scanning || result"
@click="stopScan(); clear()"
type="button"
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
{{ _('Chiudi') }}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,77 @@
<!--
Caliper Status Indicator
Compact component for navbar or header
Shows connection status and last reading
-->
<div x-data="caliperConnection()"
x-init="$watch('connected', value => console.log('Caliper connected:', value))"
class="inline-block">
<!-- Compact indicator button -->
<button @click="connected ? disconnect() : connect()"
:disabled="!isSupported"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-200 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
:class="{
'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700': status === 'disconnected',
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800': status === 'connected',
'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800': status === 'connecting',
'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800': status === 'error'
}">
<!-- Status dot with animation -->
<span class="relative flex h-2.5 w-2.5">
<!-- Ping animation for active states -->
<span x-show="status === 'connected' || status === 'connecting'"
class="absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping"
:class="{
'bg-green-400': status === 'connected',
'bg-amber-400': status === 'connecting'
}"></span>
<!-- Static dot -->
<span class="relative inline-flex rounded-full h-2.5 w-2.5"
:class="{
'bg-green-500': status === 'connected',
'bg-slate-400': status === 'disconnected',
'bg-amber-400': status === 'connecting',
'bg-red-500': status === 'error'
}"></span>
</span>
<!-- Caliper icon -->
<svg class="w-4 h-4 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<!-- Status label -->
<span class="text-xs font-medium text-slate-700 dark:text-slate-300"
x-text="status === 'connected' ? '{{ _('Calibro') }}' :
status === 'connecting' ? '{{ _('Connessione...') }}' :
status === 'error' ? '{{ _('Errore calibro') }}' :
'{{ _('Calibro') }}'"></span>
<!-- Last reading (when connected) -->
<span x-show="connected && lastReading !== null"
x-transition
class="font-mono text-xs font-semibold text-primary px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 rounded"
x-text="lastReading?.toFixed(3) + ' mm'"></span>
<!-- Not supported warning icon -->
<template x-if="!isSupported">
<div class="flex items-center gap-1.5" x-data="{ showTooltip: false }">
<svg @mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
class="w-4 h-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<!-- Tooltip -->
<div x-show="showTooltip"
x-transition
class="absolute z-50 px-2 py-1 text-xs text-white bg-slate-900 rounded shadow-lg whitespace-nowrap -translate-y-8">
{{ _('Browser non supporta Web Serial API') }}
</div>
</div>
</template>
</button>
</div>
@@ -0,0 +1,76 @@
{# Measurement Feedback Component
Mostra feedback visivo in tempo reale della misurazione corrente.
Variabili Alpine.js richieste nel parent component:
- currentValue: float | null - valore corrente misurato
- nominal: float - valore nominale
- utl: float - Upper Tolerance Limit
- uwl: float - Upper Warning Limit
- lwl: float - Lower Warning Limit
- ltl: float - Lower Tolerance Limit
- unit: string - unità di misura (es. "mm")
- passFailStatus: computed - 'pass' | 'warning' | 'fail'
- deviation: computed - scostamento dal nominale
- progressWidth: computed - larghezza barra percentuale
#}
<div class="measurement-feedback" x-show="currentValue !== null">
<!-- Status bar colorata -->
<div class="h-3 rounded-full overflow-hidden bg-slate-200 dark:bg-slate-600">
<div class="h-full rounded-full transition-all duration-300"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"
:style="'width: ' + progressWidth + '%'">
</div>
</div>
<!-- Status text + deviation -->
<div class="flex justify-between items-center mt-2">
<div class="flex items-center gap-2">
<!-- Icona stato - CONFORME -->
<template x-if="passFailStatus === 'pass'">
<span class="flex items-center gap-1 text-measure-pass font-semibold text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
{{ _('CONFORME') }}
</span>
</template>
<!-- Icona stato - ATTENZIONE -->
<template x-if="passFailStatus === 'warning'">
<span class="flex items-center gap-1 text-measure-warning font-semibold text-sm">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/>
</svg>
{{ _('ATTENZIONE') }}
</span>
</template>
<!-- Icona stato - NON CONFORME -->
<template x-if="passFailStatus === 'fail'">
<span class="flex items-center gap-1 text-measure-fail font-semibold text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('NON CONFORME') }}
</span>
</template>
</div>
<!-- Deviation con segno -->
<span class="font-mono text-sm font-medium"
:class="{
'text-measure-pass': passFailStatus === 'pass',
'text-measure-warning': passFailStatus === 'warning',
'text-measure-fail': passFailStatus === 'fail'
}"
x-text="deviation !== null ?
(deviation >= 0 ? '+' : '') + deviation.toFixed(3) + ' ' + unit : ''">
</span>
</div>
</div>
@@ -0,0 +1,32 @@
{# Next Measurement Component
Indicatore della prossima misura da effettuare.
Variabili Alpine.js richieste nel parent component:
- nextSubtask: object | null - prossimo subtask da misurare
- marker_number: int
- description: string
- nominal: float
- unit: string
#}
<div x-show="nextSubtask" class="mt-4 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-2 text-sm text-steel dark:text-steel-light">
<!-- Arrow right icon -->
<svg class="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
<span class="font-medium">{{ _('Prossima misura') }}:</span>
<span class="font-semibold text-[var(--text-primary)]">
#<span x-text="nextSubtask?.marker_number"></span>
<span x-text="nextSubtask?.description" class="ml-1"></span>
</span>
</div>
<!-- Nominal preview -->
<div class="mt-1 ml-6 text-xs text-steel dark:text-steel-light">
<span>Nom: </span>
<span class="font-mono font-medium" x-text="nextSubtask?.nominal?.toFixed(3)"></span>
<span class="ml-1" x-text="nextSubtask?.unit || 'mm'"></span>
</div>
</div>
+121
View File
@@ -0,0 +1,121 @@
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
<div
x-data="numpad()"
@keydown.window="handleKeydown($event)"
class="numpad-container w-full max-w-sm mx-auto"
>
<!-- Value Display -->
<div class="value-display mb-3 p-4 bg-white dark:bg-slate-700 rounded-lg border-2 border-slate-200 dark:border-slate-600 shadow-sm">
<div class="flex items-baseline justify-center">
<span
x-text="displayValue || '0'"
class="font-mono text-3xl font-bold text-slate-900 dark:text-white"
></span>
<span
x-text="unit"
class="ml-2 text-sm font-medium text-steel dark:text-steel-light"
></span>
</div>
</div>
<!-- Keypad Grid 4x4 -->
<div class="grid grid-cols-4 gap-2">
<!-- Row 1: 7 8 9 ⌫ -->
<button
@click="addDigit('7')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>7</button>
<button
@click="addDigit('8')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>8</button>
<button
@click="addDigit('9')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>9</button>
<button
@click="backspace()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
></button>
<!-- Row 2: 4 5 6 C -->
<button
@click="addDigit('4')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>4</button>
<button
@click="addDigit('5')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>5</button>
<button
@click="addDigit('6')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>6</button>
<button
@click="clearAll()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-measure-fail hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>C</button>
<!-- Row 3: 1 2 3 +/ -->
<button
@click="addDigit('1')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>1</button>
<button
@click="addDigit('2')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>2</button>
<button
@click="addDigit('3')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>3</button>
<button
@click="toggleSign()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>+/</button>
<!-- Row 4: 0 . (spacer) ✓ -->
<button
@click="addDigit('0')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>0</button>
<button
@click="addDecimal()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>.</button>
<!-- Spacer -->
<div></div>
<button
@click="confirm()"
type="button"
:disabled="!hasValue"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
></button>
</div>
</div>
+331
View File
@@ -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 %}
+275
View File
@@ -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 %}
+750
View File
@@ -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 %}
+247
View File
@@ -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 &middot;
{% 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 %}