Files
TieMeasureFlow/src/frontend/flask_app/templates/admin/stations.html
T
Adriano Dal Pastro 78816dfe8c fix(ui): una cornice sola, e smette di spostarsi sotto le dita
Punto 14. La segnalazione dell'operatore del 28/07 non era stata circoscritta sul
codice: «le dimensioni delle viste cambiano a seconda del menu». Sono quattro
meccanismi distinti, tutti leggibili senza avere il tablet in mano.

Ogni vista si dichiarava la propria larghezza: sette valori diversi su diciassette
template, e nessuno coincideva con quello della navbar, che sta a max-w-7xl su
tutte. Il contenuto era quindi disallineato dalla barra sopra di sé, e il
disallineamento cambiava a ogni pagina. Il percorso dell'operatore faceva
1280 → 1024 → tutto schermo → 1280 in quattro passaggi.

Il padding verticale mescolava py-8 e py-6 fra viste consecutive, così la prima
card cambiava altezza a ogni schermata.

La barra di scorrimento è classica da 8px e occupa spazio: una pagina lunga la
mostrava, una corta no, e la schermata di misura la toglie sempre. A ogni
navigazione tutto il contenuto centrato — navbar compresa — si spostava di 8px.

La schermata di misura era alta 100vh, che su Android e iOS è l'altezza con la
barra dell'indirizzo nascosta: su un tablet il piede, dove stanno «Fine ciclo
misura» e il tastierino, finiva sotto il bordo.

Ora: .tmf-page e .tmf-page-narrow in themes.css, con i valori esatti della navbar;
scrollbar-gutter: stable; 100dvh dove serve. Due larghezze in tutto il prodotto al
posto di sette, e il criterio è il tipo di pagina — liste, tabelle e tele stanno
larghe, i moduli stanno stretti.

Login e schermata di misura tengono la loro geometria, per i motivi scritti in
docs/architecture/LAYOUT.md e ripetuti in EXEMPT dentro il test: la prima non ha
navbar a cui allinearsi, la seconda non deve scorrere mentre si misura.

Il test è statico e guarda i sorgenti: la vista scritta domani copierà la cornice
dalla vicina, ed è lì che la deriva ricomincia.

Resta da chiedere all'operatore su quale schermo l'ha vista: in verticale su un
tablet quasi tutte quelle larghezze collassano e il difetto non si nota.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:41:16 +00:00

742 lines
36 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Gestione Stazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<script>
window.__stations = {{ stations|tojson }};
window.__allRecipes = {{ all_recipes|tojson }};
</script>
<div class="tmf-page"
x-data="stationManagement(window.__stations, window.__allRecipes)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Stazioni') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci stazioni di misurazione e relative ricette assegnate') }}</p>
</div>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div class="relative w-full sm:w-80">
<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="search"
placeholder="{{ _('Cerca stazione...') }}"
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)] placeholder-[var(--text-secondary)]
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
</div>
<button @click="openCreateModal()"
class="flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm">
<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="M12 4v16m8-8H4"/>
</svg>
{{ _('Nuova Stazione') }}
</button>
</div>
<!-- Stations Table -->
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
<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>
</thead>
<tbody class="divide-y divide-[var(--border-color)]">
<template x-for="station in filteredStations" :key="station.id">
<tr class="hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
@click="openEditModal(station)">
<td class="px-4 py-3">
<span class="font-mono text-sm font-semibold text-[var(--text-primary)]" x-text="station.code"></span>
</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
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
x-text="station.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'"></span>
</td>
<td class="px-4 py-3 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<button @click="openAssignmentsModal(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="'{{ _('Gestisci ricette') }}'">
<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>
</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') }}'">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="confirmDelete(station)"
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"
:title="'{{ _('Elimina') }}'">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="filteredStations.length === 0">
<div class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto text-[var(--text-secondary)] opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
<p class="mt-3 text-sm text-[var(--text-secondary)]">{{ _('Nessuna stazione trovata') }}</p>
</div>
</template>
</div>
<div class="mt-3 text-xs text-[var(--text-secondary)]">
<span x-text="filteredStations.length"></span> {{ _('stazioni') }}
</div>
<!-- Modal: Create / Edit Station -->
<div x-show="showModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeModal()">
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
<div x-show="showModal"
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"
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<h2 class="text-lg font-semibold text-[var(--text-primary)]"
x-text="isEditing ? '{{ _('Modifica Stazione') }}' : '{{ _('Nuova Stazione') }}'"></h2>
<button @click="closeModal()" class="p-1 rounded-lg text-[var(--text-secondary)] 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>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Codice') }} <span class="text-red-500">*</span></label>
<input type="text" x-model="form.code"
:disabled="isEditing"
:class="isEditing ? 'opacity-50 cursor-not-allowed font-mono' : 'font-mono uppercase'"
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"
placeholder="ST-001"
maxlength="100">
<template x-if="isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Il codice non può essere modificato') }}</p>
</template>
<template x-if="!isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Identificativo univoco usato dal client tramite STATION_CODE') }}</p>
</template>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nome') }} <span class="text-red-500">*</span></label>
<input type="text" x-model="form.name"
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"
placeholder="{{ _('Nome descrittivo della stazione') }}"
maxlength="255">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Postazione') }}</label>
<input type="text" x-model="form.location"
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"
placeholder="{{ _('Es. Reparto A - Linea 2') }}"
maxlength="255">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
<textarea x-model="form.notes" rows="3"
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"
placeholder="{{ _('Note opzionali') }}"></textarea>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.active"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm font-medium text-[var(--text-primary)]">{{ _('Attiva') }}</span>
</label>
</div>
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeModal()"
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="saveStation()"
:disabled="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">
<span x-show="!saving" x-text="isEditing ? '{{ _('Salva Modifiche') }}' : '{{ _('Crea Stazione') }}'"></span>
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span>
</button>
</div>
</div>
</div>
<!-- Modal: Recipe Assignments -->
<div x-show="showAssignments" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeAssignmentsModal()">
<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-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>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Ricette Assegnate') }}</h2>
<p class="text-xs text-[var(--text-secondary)] mt-0.5" x-text="assignmentStation ? assignmentStation.code + ' — ' + assignmentStation.name : ''"></p>
</div>
<button @click="closeAssignmentsModal()" class="p-1 rounded-lg text-[var(--text-secondary)] 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>
<div class="px-6 py-4 space-y-4">
<!-- 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>
<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 p-2 space-y-2 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 border border-[var(--border-color)] rounded-md 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>
</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 p-2 space-y-2 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 border border-[var(--border-color)] rounded-md 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">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeAssignmentsModal()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Chiudi') }}
</button>
</div>
</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">
<div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false"></div>
<div x-show="showDeleteConfirm"
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">{{ _('Conferma Eliminazione') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Sei sicuro di voler eliminare la stazione') }}
<strong x-text="deleteTarget?.code"></strong>?
<br><span class="text-xs text-red-600 dark:text-red-400">{{ _('Verranno rimosse anche tutte le assegnazioni di ricette.') }}</span>
</p>
<div class="flex justify-end gap-3">
<button @click="showDeleteConfirm = 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="performDelete()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm bg-red-600 hover:bg-red-700 disabled:opacity-50 transition-colors">
{{ _('Elimina') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function stationManagement(initialStations, initialRecipes) {
return {
stations: initialStations || [],
allRecipes: initialRecipes || [],
search: '',
showModal: false,
showAssignments: false,
showDeleteConfirm: false,
showResetConfirm: false,
isEditing: false,
editingId: null,
saving: false,
errorMsg: '',
deleteTarget: null,
resetTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeSearch: '',
form: {
code: '',
name: '',
location: '',
notes: '',
active: true,
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredStations() {
if (!this.search.trim()) return this.stations;
const q = this.search.toLowerCase();
return this.stations.filter(s =>
s.code.toLowerCase().includes(q) ||
s.name.toLowerCase().includes(q) ||
(s.location && s.location.toLowerCase().includes(q))
);
},
get unassignedRecipes() {
const assignedIds = new Set(this.assignedRecipes.map(r => r.id));
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;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', active: true };
this.showModal = true;
},
openEditModal(station) {
this.isEditing = true;
this.editingId = station.id;
this.errorMsg = '';
this.form = {
code: station.code,
name: station.name,
location: station.location || '',
notes: station.notes || '',
active: station.active,
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveStation() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
const data = {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
active: this.form.active,
};
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nel salvataggio") }}';
return;
}
const idx = this.stations.findIndex(s => s.id === this.editingId);
// 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") }}';
return;
}
const data = {
code: this.form.code.trim(),
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
active: this.form.active,
};
const resp = await fetch('/admin/api/stations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nella creazione") }}';
return;
}
this.stations.push({ ...result, recipes: [] });
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
confirmDelete(station) {
this.deleteTarget = station;
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;
try {
const resp = await fetch(`/admin/api/stations/${this.deleteTarget.id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
alert(result.detail || "{{ _('Errore nella eliminazione') }}");
return;
}
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
this.showDeleteConfirm = false;
this.deleteTarget = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
},
async openAssignmentsModal(station) {
this.assignmentStation = station;
this.assignedRecipes = [];
this.recipeSearch = '';
this.errorMsg = '';
this.showAssignments = true;
try {
const resp = await fetch(`/admin/api/stations/${station.id}/recipes`);
const data = await resp.json();
if (resp.ok && Array.isArray(data)) {
this.assignedRecipes = data;
} else {
this.errorMsg = (data && data.detail) || '{{ _("Errore nel caricamento delle ricette") }}';
}
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
}
},
closeAssignmentsModal() {
this.showAssignments = false;
this.assignmentStation = null;
this.assignedRecipes = [];
this.recipeSearch = '';
this.errorMsg = '';
},
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: 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 === id);
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 {
this.saving = false;
}
},
async unassignRecipe(recipeId) {
if (!this.assignmentStation) return;
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes/${recipeId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
this.errorMsg = result.detail || '{{ _("Errore nella rimozione") }}';
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>
{% endblock %}