feat(client): add admin GUI for stations CRUD and recipe assignments
Adds a complete browser-based interface for managing stations, closing the last deliverable of rev04 Phase 1. - New /admin/stations page with stations table, create/edit modal, delete confirmation and dedicated recipe-assignment modal - Proxy endpoints under /admin/api/stations/* covering CRUD and recipe assign/unassign so all admin operations stay behind the Flask CSRF + admin_required guard - Navbar entry "Stazioni" (desktop + mobile), visible to admins only - 10 new tests covering page render, every proxy and the non-admin redirect Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Gestione Stazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
window.__stations = {{ stations|tojson }};
|
||||
window.__allRecipes = {{ all_recipes|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
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-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 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="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-2xl 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">
|
||||
<!-- 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>
|
||||
</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>
|
||||
<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>
|
||||
</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 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,
|
||||
isEditing: false,
|
||||
editingId: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
deleteTarget: null,
|
||||
assignmentStation: null,
|
||||
assignedRecipes: [],
|
||||
recipeToAdd: '',
|
||||
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);
|
||||
},
|
||||
|
||||
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);
|
||||
if (idx >= 0) this.stations[idx] = result;
|
||||
} 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);
|
||||
}
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete(station) {
|
||||
this.deleteTarget = station;
|
||||
this.showDeleteConfirm = true;
|
||||
},
|
||||
|
||||
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 nell\'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.recipeToAdd = '';
|
||||
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.errorMsg = '';
|
||||
},
|
||||
|
||||
async assignRecipe() {
|
||||
if (!this.recipeToAdd || !this.assignmentStation) return;
|
||||
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) }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
|
||||
return;
|
||||
}
|
||||
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
|
||||
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 {
|
||||
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);
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -79,7 +79,7 @@
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('admin.') %}
|
||||
{% if request.endpoint == 'admin.user_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Users Icon -->
|
||||
@@ -88,6 +88,20 @@
|
||||
</svg>
|
||||
<span>{{ _('Utenti') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.station_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Station / Workstation Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" 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>
|
||||
<span>{{ _('Stazioni') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -264,7 +278,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti #}
|
||||
{# Admin: Utenti + Stazioni #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
@@ -275,6 +289,16 @@
|
||||
</svg>
|
||||
{{ _('Utenti') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<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="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>
|
||||
{{ _('Stazioni') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user