2453e552fb
- Extract JSON data to <script> tags instead of tojson_attr in x-data attributes
- Remove literal " from CSS selector in x-data (meta[name=csrf-token])
- Move Alpine.js defer script after extra_js block in base.html
- Add isinstance(resp, dict) guard before .get("error") in measure.py and maker.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
333 lines
15 KiB
HTML
333 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ _('Seleziona Ricetta') }} — TieMeasureFlow{% endblock %}
|
|
|
|
{% block content %}
|
|
<script>window.__selectRecipeData = {{ recipes|tojson }};</script>
|
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
|
|
x-data="{
|
|
recipes: window.__selectRecipeData,
|
|
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 %}
|