Files
TieMeasureFlow/src/frontend/flask_app/templates/measure/select_recipe.html
T

433 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: '',
/* #63/#64: ricetta scelta che richiede lotto/seriale; il pannello compare solo allora. */
selected: null,
needsTrace(recipe) {
return !!(recipe && (recipe.requires_lot || recipe.requires_serial));
},
choose(recipe) {
this.selected = recipe;
this.$nextTick(() => { const el = document.getElementById('trace-panel'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); });
},
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.
// #63/#64: se la ricetta chiede lotto/seriale mancanti, la si sceglie e
// compare il pannello con i soli campi richiesti.
if (this.missingTraceability(data).length) {
this.barcodeModal = false;
this.barcodeInput = '';
this.choose(data);
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>
<!-- 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">
{# #63/#64: una ricetta con lotto/seriale obbligatori si sceglie prima
(il pannello compare sotto la griglia); le altre partono subito. #}
<template x-if="needsTrace(recipe)">
<button type="button" @click="choose(recipe)"
:class="selected && selected.id === recipe.id ? 'ring-2 ring-primary' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold">
{{ _('Seleziona') }}
</button>
</template>
<template x-if="!needsTrace(recipe)">
<a :href="buildTaskUrl(recipe.id)"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200 group-hover:shadow-md">
<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 ATTIVITÀ') }}
</a>
</template>
{# 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>
<!-- Traceability (#63/#64): compare solo dopo aver scelto una ricetta con
requires_lot / requires_serial, con i soli campi richiesti. -->
<div id="trace-panel" x-show="selected && needsTrace(selected)" x-cloak
class="tmf-card mt-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à') }}: <span x-text="selected ? selected.name : ''"></span></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">
<div x-show="selected && selected.requires_lot">
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
<input type="text" x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}" class="tmf-input font-mono">
</div>
<div x-show="selected && selected.requires_serial">
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
<input type="text" x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}" class="tmf-input font-mono">
</div>
</div>
<a :href="selected && !missingTraceability(selected).length ? buildTaskUrl(selected.id) : null"
:aria-disabled="!selected || missingTraceability(selected).length > 0"
:class="(!selected || missingTraceability(selected).length) ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold mt-4">
{{ _('AVVIA ATTIVITÀ') }}
</a>
</div>
</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 %}