feat(stations): ricette in lista, reset per riga e cambio stazione al volo

Punto 7 del documento modifiche del 28/07, terza richiesta di Menoncin.

GET /api/stations ora usa StationWithRecipesResponse, che era gia' definita nello
schema senza che nessun endpoint la usasse. Non costa query in piu':
Station.assignments e' gia' selectin-loaded. In lista compaiono le sole ricette
attive, coerente con cio' che l'operatore vede davvero a quella stazione.

DELETE /api/stations/{id}/recipes azzera le assegnazioni di una stazione senza
eliminarla, cosi' si riassegna da capo. E' idempotente: azzerare una stazione gia'
vuota risponde removed=0, non 404. In tabella e' un pulsante per riga, con
conferma perche' l'azione e' distruttiva.

Il cambio stazione al volo (?station=CODE, override in sessione) serve al
collaudo: senza, provare N stazioni richiede N PC. E' dietro il flag
STATION_SWITCH_ENABLED, default 0. Non e' legato al ruolo admin perche'
/measure/select richiede MeasurementTec, e gatearlo su is_admin avrebbe escluso
proprio chi fa il collaudo. In fabbrica l'identita' della stazione viene
dall'installazione locale, e misurare contro le ricette di un'altra stazione
romperebbe in silenzio la tracciabilita': per questo il codice richiesto e'
validato prima di essere memorizzato - un refuso lascia l'operatore sulla
stazione configurata con un messaggio, invece di incastrarlo su una stazione
inesistente - e quando l'override e' attivo la pagina lo dichiara, con il codice
configurato accanto e un link per ripristinare.

Le traduzioni: pybabel update aveva indovinato dieci voci da stringhe simili
marcandole fuzzy, in entrambi i cataloghi. "Azzera Stazione" era diventato "Crea
Stazione", l'opposto, su un dialogo distruttivo. Non si vedeva perche' la
compilazione salta le fuzzy e ricade sul sorgente italiano, ma sarebbe emerso al
primo build con --use-fuzzy. Tradotte per esteso le 17 stringhe nuove in IT ed EN
e tolti i flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 15:42:17 +00:00
parent 28ee44bcf1
commit a159eb2b8b
15 changed files with 1188 additions and 363 deletions
@@ -49,6 +49,7 @@
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Codice') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
@@ -62,6 +63,24 @@
</td>
<td class="px-4 py-3 text-sm text-[var(--text-primary)]" x-text="station.name"></td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="station.location || '-'"></td>
<td class="px-4 py-3 hidden lg:table-cell">
<template x-if="!(station.recipes && station.recipes.length)">
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna ricetta') }}</span>
</template>
<template x-if="station.recipes && station.recipes.length">
<div class="flex flex-wrap items-center gap-1">
<template x-for="r in station.recipes.slice(0, 3)" :key="r.id">
<span class="inline-flex px-1.5 py-0.5 rounded font-mono text-[11px] font-medium
bg-primary-50 dark:bg-primary-900/20 text-primary"
:title="r.name" x-text="r.code"></span>
</template>
<template x-if="station.recipes.length > 3">
<span class="text-[11px] text-[var(--text-secondary)]"
x-text="'+' + (station.recipes.length - 3)"></span>
</template>
</div>
</template>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
@@ -78,6 +97,17 @@
<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>
</button>
<button @click="confirmReset(station)"
:disabled="!(station.recipes && station.recipes.length)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors
disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-[var(--text-secondary)] disabled:hover:bg-transparent"
:title="(station.recipes && station.recipes.length)
? '{{ _('Azzera le ricette della stazione') }}'
: '{{ _('Nessuna ricetta da azzerare') }}'">
<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="M16.023 9.348h4.992V4.356m-.582 9.66a8.25 8.25 0 01-15.356 2.008M3.985 14.652h4.992v4.992m.581-9.66a8.25 8.25 0 0115.356-2.008"/>
</svg>
</button>
<button @click="openEditModal(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
:title="'{{ _('Modifica') }}'">
@@ -335,6 +365,38 @@
</div>
</div>
<!-- Confirm Reset Modal -->
<div x-show="showResetConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="showResetConfirm = false">
<div class="absolute inset-0 bg-black/50" @click="showResetConfirm = false"></div>
<div x-show="showResetConfirm"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">{{ _('Azzera Stazione') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Rimuovere tutte le ricette assegnate a') }}
<strong x-text="resetTarget?.code"></strong>?
<br>
<span class="text-xs text-[var(--text-secondary)]">
<span x-text="resetTarget?.recipes?.length || 0"></span>
{{ _('assegnazioni verranno rimosse. La stazione resta, e si potrà riassegnare.') }}
</span>
</p>
<div class="flex justify-end gap-3">
<button @click="showResetConfirm = false"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="performReset()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm bg-amber-600 hover:bg-amber-700 disabled:opacity-50 transition-colors">
{{ _('Azzera') }}
</button>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div x-show="showDeleteConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4">
@@ -375,11 +437,13 @@ function stationManagement(initialStations, initialRecipes) {
showModal: false,
showAssignments: false,
showDeleteConfirm: false,
showResetConfirm: false,
isEditing: false,
editingId: null,
saving: false,
errorMsg: '',
deleteTarget: null,
resetTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeSearch: '',
@@ -486,7 +550,9 @@ function stationManagement(initialStations, initialRecipes) {
return;
}
const idx = this.stations.findIndex(s => s.id === this.editingId);
if (idx >= 0) this.stations[idx] = result;
// PUT returns the station without its assignments; keep the ones the
// row already knows about or the "ricette collegate" column blanks out.
if (idx >= 0) this.stations[idx] = { ...result, recipes: this.stations[idx].recipes || [] };
} else {
if (!this.form.code || !this.form.name) {
this.errorMsg = '{{ _("Codice e nome sono obbligatori") }}';
@@ -509,7 +575,7 @@ function stationManagement(initialStations, initialRecipes) {
this.errorMsg = result.detail || '{{ _("Errore nella creazione") }}';
return;
}
this.stations.push(result);
this.stations.push({ ...result, recipes: [] });
}
this.closeModal();
} catch (e) {
@@ -524,6 +590,40 @@ function stationManagement(initialStations, initialRecipes) {
this.showDeleteConfirm = true;
},
confirmReset(station) {
if (!station.recipes || station.recipes.length === 0) return;
this.resetTarget = station;
this.showResetConfirm = true;
},
async performReset() {
if (!this.resetTarget) return;
this.saving = true;
try {
const resp = await fetch(`/admin/api/stations/${this.resetTarget.id}/recipes`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
alert(result.detail || '{{ _("Errore nell azzeramento") }}');
return;
}
// resetTarget is the same object the row renders, so clearing it here
// updates the chips column without reloading the page.
this.resetTarget.recipes = [];
if (this.assignmentStation && this.assignmentStation.id === this.resetTarget.id) {
this.assignedRecipes = [];
}
this.showResetConfirm = false;
this.resetTarget = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
},
async performDelete() {
if (!this.deleteTarget) return;
this.saving = true;
@@ -591,7 +691,11 @@ function stationManagement(initialStations, initialRecipes) {
return;
}
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 });
if (recipe) {
const summary = { id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active };
this.assignedRecipes.push(summary);
this._syncStationRecipes();
}
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
@@ -614,12 +718,23 @@ function stationManagement(initialStations, initialRecipes) {
return;
}
this.assignedRecipes = this.assignedRecipes.filter(r => r.id !== recipeId);
this._syncStationRecipes();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
/* Mirror the modal's assignment list onto the table row, so the chips in
"ricette collegate" stay true after assigning or unassigning. Sorted by
code to match the order the server returns. */
_syncStationRecipes() {
if (!this.assignmentStation) return;
this.assignmentStation.recipes = [...this.assignedRecipes]
.filter(r => r.active !== false)
.sort((a, b) => (a.code || '').localeCompare(b.code || ''));
},
};
}
</script>