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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user