72f0cb1f1b
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>
435 lines
21 KiB
HTML
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 %}
|