fix(admin): two more apostrophe-in-translation regressions + UX rework

UX rework — recipe assignment modal on /admin/stations
- Replace the single "select recipe + Assegna" dropdown with a two-
  column layout: Ricette disponibili (left) and Assegnate alla
  stazione (right), each row with an inline action button. Top search
  filter narrows both columns at once. Empty states explain *why* the
  list is empty (no recipes in system, all already assigned, no match
  for the filter).
- Rationale: the old dropdown silently hid every option once a recipe
  was assigned, leaving the user unable to tell whether the system
  was broken or simply out of unassigned recipes.

Apostrophe regressions
- /admin/stations alert/errorMsg literals reworded with double-quoted
  outer JS strings ("Errore nella eliminazione" / "...assegnazione").
- /admin/users toggle confirm modal: x-text expression contained
  '{{ _('… l\'utente') }}'. Inside a Jinja-rendered HTML attribute,
  the apostrophe in "l'utente" closed the JS literal early, killing
  the binding. Fixed by using " as the JS string delimiter so
  the inner apostrophe is harmless.

Alpine x-if templates can't host nested templates
- Replaced two nested-template empty-state blocks with x-text bound
  to computed getters (unassignedEmptyMessage,
  assignedEmptyMessage). Alpine errored with
  "Cannot set properties of null (setting '_x_dataStack')" when the
  outer template's child wasn't a single root element.

Test guard widened
- src/frontend/flask_app/tests/test_template_js_syntax.py now also
  parses every Alpine attribute (x-*, @*, :*) on the rendered HTML
  and runs `node --check` on each expression wrapped in `void (…)`.
  Previously it only inspected inline <script> bodies, which is why
  the x-text bug on /admin/users slipped through. Verified the
  extended test catches the original l'utente regression by reverting
  + running + restoring.

Layout regression — UPLOAD_DIR defaulted to server/uploads
- The previous .env.example shipped UPLOAD_DIR=server/uploads, which
  matched the V1.x layout but pointed outside the new project tree.
  Updated to UPLOAD_DIR=uploads so files land in the project-root
  uploads/ volume that src/backend/config.py.upload_path resolves.
- Added uploads/general/ to .gitignore (per-user uploads, not source).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 14:49:23 +02:00
parent 6e284b0c0c
commit 2a2d40bec9
5 changed files with 192 additions and 63 deletions
@@ -221,7 +221,7 @@
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
<div x-show="showAssignments"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<div>
@@ -236,55 +236,86 @@
</div>
<div class="px-6 py-4 space-y-4">
<!-- Add recipe -->
<div class="flex items-end gap-2">
<div class="flex-1">
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
<select x-model="recipeToAdd"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="">{{ _('-- Seleziona ricetta --') }}</option>
<template x-for="r in unassignedRecipes" :key="r.id">
<option :value="r.id" x-text="r.code + ' — ' + r.name"></option>
</template>
</select>
</div>
<button @click="assignRecipe()"
:disabled="!recipeToAdd || saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
{{ _('Assegna') }}
</button>
<!-- Search filter -->
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" 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>
<input type="text" x-model="recipeSearch"
placeholder="{{ _('Filtra per codice o nome ricetta...') }}"
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
</div>
<!-- Assigned list -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in assignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div>
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Available recipes (left column) -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 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>
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in filteredUnassignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
</div>
<button @click="assignRecipe(r.id)"
:disabled="saving"
class="ml-2 inline-flex items-center gap-1 px-2.5 py-1 bg-primary text-white text-xs font-medium rounded-md
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50"
:title="'{{ _('Assegna a questa stazione') }}'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Assegna') }}
</button>
</div>
<button @click="unassignRecipe(r.id)"
:disabled="saving"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi') }}'">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<template x-if="assignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
{{ _('Nessuna ricetta assegnata') }}
</div>
</template>
</template>
<template x-if="filteredUnassignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
x-text="unassignedEmptyMessage"></div>
</template>
</div>
</div>
<!-- Assigned recipes (right column) -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in filteredAssignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
</div>
<button @click="unassignRecipe(r.id)"
:disabled="saving"
class="ml-2 p-1.5 rounded-md text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi assegnazione') }}'">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<template x-if="filteredAssignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
x-text="assignedEmptyMessage"></div>
</template>
</div>
</div>
</div>
<template x-if="errorMsg">
@@ -351,7 +382,7 @@ function stationManagement(initialStations, initialRecipes) {
deleteTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeToAdd: '',
recipeSearch: '',
form: {
code: '',
name: '',
@@ -380,6 +411,32 @@ function stationManagement(initialStations, initialRecipes) {
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
},
_matchesRecipeSearch(r) {
if (!this.recipeSearch.trim()) return true;
const q = this.recipeSearch.toLowerCase();
return (r.code && r.code.toLowerCase().includes(q)) ||
(r.name && r.name.toLowerCase().includes(q));
},
get filteredUnassignedRecipes() {
return this.unassignedRecipes.filter(r => this._matchesRecipeSearch(r));
},
get filteredAssignedRecipes() {
return this.assignedRecipes.filter(r => this._matchesRecipeSearch(r));
},
get unassignedEmptyMessage() {
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
if (this.allRecipes.length === 0) return '{{ _("Nessuna ricetta nel sistema") }}';
return '{{ _("Tutte le ricette sono già assegnate") }}';
},
get assignedEmptyMessage() {
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
return '{{ _("Nessuna ricetta assegnata") }}';
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
@@ -493,7 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
async openAssignmentsModal(station) {
this.assignmentStation = station;
this.assignedRecipes = [];
this.recipeToAdd = '';
this.recipeSearch = '';
this.errorMsg = '';
this.showAssignments = true;
try {
@@ -513,27 +570,28 @@ function stationManagement(initialStations, initialRecipes) {
this.showAssignments = false;
this.assignmentStation = null;
this.assignedRecipes = [];
this.recipeSearch = '';
this.errorMsg = '';
},
async assignRecipe() {
if (!this.recipeToAdd || !this.assignmentStation) return;
async assignRecipe(recipeId) {
if (!recipeId || !this.assignmentStation) return;
const id = parseInt(recipeId, 10);
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ recipe_id: parseInt(this.recipeToAdd, 10) }),
body: JSON.stringify({ recipe_id: id }),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
return;
}
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
const recipe = this.allRecipes.find(r => r.id === id);
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
this.recipeToAdd = '';
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
@@ -307,8 +307,8 @@
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
<span x-text="toggleUser?.active
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
? &quot;{{ _('Sei sicuro di voler disattivare l\'utente') }}&quot;
: &quot;{{ _('Sei sicuro di voler riattivare l\'utente') }}&quot;"></span>
<strong x-text="toggleUser?.username"></strong>?
</p>
<div class="flex justify-end gap-3">