Files
TieMeasureFlow/src/frontend/flask_app/templates/measure/select_recipe.html
T
Adriano 72f0cb1f1b fix(frontend): #37 punti 1,4,5,6 - 502 backend giù, barcode quotato, apici Alpine, paginazione ricette
1. api_client.py: ConnectionError/Timeout restituisce status_code=502 invece
   di 0 (nessuna route confrontava == 0, verificato); i proxy che inoltrano
   resp.get("status_code", 500) ereditano il fix senza altre modifiche.
4. measure.py lookup_barcode: code non stringa -> 400 (prima 500 su
   code.strip()); urllib.parse.quote(code, safe="") prima di comporre l'URL
   verso il backend - un barcode con '/' o '../' non spezza più il path né
   raggiunge altri GET con i permessi dell'utente.
5. Ogni "'{{ _(...) }}'" dentro un attributo Alpine (x-text, :title,
   :placeholder, @click) sostituito con "{{ _(...)|tojson_attr }}": la
   traduzione EN di admin/stations.html:105 ("Clear the station's recipes")
   ha un apostrofo che l'IT non ha, e rompeva quel binding e basta - non è
   un XSS, ma stessa classe di difetto di #10. File toccati: admin/stations,
   admin/users, maker/recipe_list, maker/recipe_editor, maker/task_editor,
   maker/task_drawing, components/caliper_status, measure/select_recipe.
   Le occorrenze dentro <script>...</script> (non attributi) sono fuori
   perimetro per questo punto.
6. statistics.py dashboard(): ciclo sulle pagine (per_page=100, il massimo
   del backend) invece di un'unica chiamata che troncava la tendina ricette
   alla centesima.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:03:06 +02:00

435 lines
21 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Seleziona Ricetta') }} — TieMeasureFlow{% endblock %}
{% block content %}
<script>window.__selectRecipeData = {{ recipes|tojson }};</script>
<div class="tmf-page"
x-data="{
recipes: window.__selectRecipeData,
search: {{ auto_recipe_code|tojson_attr }},
lot_number: {{ auto_lot|tojson_attr }},
serial_number: {{ auto_serial|tojson_attr }},
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)
);
},
/* Which compulsory traceability fields this recipe is still missing.
Empty means the operator can start. The server refuses the same thing
when the production opens - this is only the earlier answer. */
missingTraceability(recipe) {
const missing = [];
if (recipe.requires_lot && !this.lot_number.trim()) missing.push({{ _('lotto')|tojson_attr }});
if (recipe.requires_serial && !this.serial_number.trim()) missing.push({{ _('seriale')|tojson_attr }});
return missing;
},
/* Straight into the first task. Choosing a recipe used to open the full
task list, which asked the operator to decide where to begin before they
had done anything; the list is still one tap away from inside the task. */
buildTaskUrl(recipeId) {
let url = '/measure/start/' + 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")|tojson_attr }};
} else {
// The scanner is a way in like any other: a recipe that demands a lot
// demands it here too, or the rule would last exactly as long as it
// took someone to reach for the barcode reader.
const missing = this.missingTraceability(data);
if (missing.length) {
this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', ');
return;
}
this.barcodeModal = false;
this.barcodeInput = '';
window.location.href = this.buildTaskUrl(data.id);
}
} catch (e) {
this.barcodeError = {{ _("Errore di connessione")|tojson_attr }};
} finally {
this.barcodeLoading = false;
}
}
}">
{# A production open at this station keeps its clock on screen even here: the
interval runs whether or not the operator is looking at the measurement. #}
{% include "components/production_clock.html" %}
<!-- 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>
<p class="mt-1 text-sm text-steel-500 dark:text-steel-400">
{{ _('Stazione') }}: <span class="font-mono font-bold">{{ station_code }}</span>
</p>
{% if station_overridden %}
<p class="mt-2 inline-flex flex-wrap items-center gap-2 px-2.5 py-1 rounded-lg
bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700
text-xs text-amber-800 dark:text-amber-200">
<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 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
<span>
{{ _('Stazione forzata per collaudo') }}{% if station_configured %} — {{ _('configurata') }}:
<span class="font-mono">{{ station_configured }}</span>{% endif %}
</span>
<a href="{{ url_for('measure.select_recipe', station='') }}"
class="font-medium underline underline-offset-2 hover:no-underline">
{{ _('Ripristina') }}
</a>
</p>
{% endif %}
</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 Bar (filter) -->
<div class="tmf-card mb-4">
<div class="p-4 sm:p-5">
<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>
</div>
<!-- Traceability (data entry, NOT a search filter) -->
<div class="tmf-card mb-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary 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>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Lot Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" 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>
{{ _('Numero Lotto') }}
</label>
<input type="text"
x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}"
class="tmf-input font-mono">
</div>
<!-- Serial Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
{{ _('Numero Seriale') }}
</label>
<input type="text"
x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}"
class="tmf-input font-mono">
</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.current_version.version_number : (recipe.version || ''))">
</span>
</div>
<!-- Title + Thumbnail row -->
<div class="flex items-start gap-3 mb-2">
<!-- Thumbnail (fit, no crop) -->
<template x-if="recipe.image_path">
<div class="w-16 h-16 rounded-md overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
<img :src="'/measure/api/files/' + recipe.image_path"
class="max-w-full max-h-full object-contain"
loading="lazy"
onerror="this.parentElement.style.display='none'">
</div>
</template>
<div class="min-w-0 flex-1">
<!-- Recipe Name -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] leading-snug"
x-text="recipe.name">
</h3>
<!-- Description -->
<p class="mt-1 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
x-text="recipe.description || {{ _('Nessuna descrizione disponibile')|tojson_attr }}">
</p>
</div>
</div>
<!-- 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">
{# What this recipe insists on before anyone measures. Said on the card
rather than after the fact: a measurement taken without its lot cannot
be traced back, and finding that out later is finding it out too late. #}
<template x-if="missingTraceability(recipe).length">
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
<span x-text="{{ _('Compila prima:')|tojson_attr }} + ' ' + missingTraceability(recipe).join(', ')"></span>
</p>
</template>
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)"
:aria-disabled="missingTraceability(recipe).length > 0"
:class="missingTraceability(recipe).length
? 'opacity-40 cursor-not-allowed pointer-events-none'
: 'group-hover:shadow-md'"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200">
<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 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA IN SEQUENZA') }}
</a>
{# The whole list, for when the operator wants to see it or go back to a
task left unfinished. Deliberately the quiet one of the two. #}
<a :href="'/measure/tasks/' + recipe.id"
class="mt-2 block text-center text-xs text-[var(--text-secondary)]
hover:text-primary transition-colors">
{{ _('Vedi tutti i task') }}
</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 %}