++ fix e controlli

This commit is contained in:
2026-07-30 17:05:39 +02:00
parent 712336d652
commit 440d63a6b9
54 changed files with 4921 additions and 1322 deletions

View File

@@ -61,22 +61,18 @@
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
</div>
<button type="submit"
class="w-full inline-flex items-center justify-center px-4 py-2.5 text-sm font-medium text-white rounded-lg transition"
style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
Salva la nuova password
</button>
<x-btn variante="primary" type="submit" class="w-full">Salva la nuova password</x-btn>
</form>
<div class="flex justify-between text-xs text-gray-500 pt-1 border-t border-gray-100">
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 pt-1 border-t border-gray-100">
@if($forced)
<span>Non sei tu? Esci e accedi con un altro account.</span>
@else
<a href="{{ route('dashboard') }}" class="hover:text-indigo-600">Torna alla home</a>
<x-btn variante="ghost" dimensione="sm" :href="route('dashboard')">Torna alla home</x-btn>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="hover:text-indigo-600">Esci</button>
<x-btn variante="ghost" dimensione="sm" type="submit">Esci</x-btn>
</form>
</div>
</div>

View File

@@ -0,0 +1,61 @@
{{--
Pulsante unico dell'applicazione.
Esiste per un motivo preciso: prima ogni pagina si scriveva i propri
pulsanti, e in quattro modi diversi — style= inline con i colori a mano (piu'
onmouseover per l'hover), catene di utility Tailwind ripetute a memoria,
.btn-action* in sei tinte, .btn-primary-* in due. La stessa azione cambiava
aspetto da una pagina all'altra, e nessuno di quei pulsanti aveva uno stato
di focus. Le classi stanno in resources/css/app.css.
variante:
primary l'azione principale. Una sola per schermata o per zona.
secondary tutto il resto: dettaglio, modifica, scarica, filtra.
danger SOLO dove si cancellano o si sovrascrivono dati.
ghost annulla, chiudi, pulisci.
tono:
solido il pieno (default). Per le azioni isolate.
tenue la tinta chiara. Per le azioni dentro le righe di un elenco,
dove cinquanta pulsanti pieni diventano rumore.
velato per i comandi sopra una superficie colorata (la testata). Il
colore lo dà lo sfondo; qui `variante` non ha effetto.
dimensione: sm per le righe di elenco.
icona: true per i pulsanti di sola icona. Richiede un aria-label,
altrimenti chi usa un lettore di schermo trova un pulsante
senza nome — title non basta.
Con href rende un <a>, altrimenti un <button type=button> — il tipo si puo'
sovrascrivere (type=submit) come qualsiasi altro attributo.
ATTENZIONE, per disabilitarlo: si scrive :disabled="$condizione", NON
@disabled($condizione). Dentro un tag di componente le direttive Blade non
vengono compilate: il tag <x-btn> resta letterale nel PHP generato mentre il
</x-btn> viene invece chiuso, e la vista muore con "syntax error, unexpected
token endif". Il binding funziona come ci si aspetta perche' la borsa degli
attributi salta i valori false e rende disabled="disabled" per i true.
--}}
@props([
'variante' => 'secondary',
'tono' => 'solido',
'dimensione' => null,
'icona' => false,
'href' => null,
])
@php
$classi = implode(' ', array_filter([
'tm-btn',
'tm-btn-' . $variante,
in_array($tono, ['tenue', 'velato'], true) ? 'tm-btn-' . $tono : null,
$dimensione === 'sm' ? 'tm-btn-sm' : null,
$icona ? 'tm-btn-icona' : null,
]));
@endphp
@if ($href)
<a href="{{ $href }}" {{ $attributes->class($classi) }}>{{ $slot }}</a>
@else
<button type="{{ $attributes->get('type', 'button') }}" {{ $attributes->except('type')->class($classi) }}>{{ $slot }}</button>
@endif

View File

@@ -15,12 +15,13 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-3">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2 rounded-lg" style="background:rgba(255,255,255,0.1)"
x-data x-on:click="$dispatch('toggle-sidebar')">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<x-btn tono="velato" icona class="lg:hidden"
x-data x-on:click="$dispatch('toggle-sidebar')"
aria-label="Apri il menu di navigazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</x-btn>
@php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path)
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
@@ -32,21 +33,20 @@
</span>
</div>
<div class="flex items-center gap-4">
<a href="{{ route('profile') }}" title="Il mio profilo"
class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg transition {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}"
style="background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
<svg class="h-5 w-5 flex-none" style="color:rgba(255,255,255,0.7)" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo"
class="hidden sm:flex {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="flex flex-col leading-tight text-left">
<span class="text-sm font-medium" style="color:rgba(255,255,255,0.95)">{{ auth()->user()->name }}</span>
<span class="text-xs" style="color:rgba(255,255,255,0.6)">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span>
</a>
</x-btn>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition" style="color:rgba(255,255,255,0.8);background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<x-btn tono="velato" dimensione="sm" type="submit">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Esci
</button>
</x-btn>
</form>
</div>
</div>
@@ -192,9 +192,10 @@
class="mb-4 rounded-lg p-4 text-sm border flex items-center gap-3" style="background:#fef2f2;border-color:#fecaca;color:#991b1b">
<svg class="h-5 w-5 flex-shrink-0" style="color:#ef4444" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('error') }}
<button @click="show = false" class="ml-auto" style="color:#991b1b;opacity:0.5" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<x-btn variante="ghost" dimensione="sm" icona class="ml-auto"
@click="show = false" aria-label="Chiudi il messaggio">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</x-btn>
</div>
@endif

View File

@@ -11,15 +11,15 @@
<div x-data="{ formOpen: true }">
{{-- Toggle form --}}
<button type="button"
x-on:click="formOpen = !formOpen"
class="mb-4 inline-flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-800 transition">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<x-btn variante="primary" class="mb-4"
x-on:click="formOpen = !formOpen"
x-bind:aria-expanded="formOpen ? 'true' : 'false'">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path x-show="formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
<path x-show="!formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
<span x-text="formOpen ? 'Nascondi form' : 'Nuova assegnazione'"></span>
</button>
</x-btn>
{{-- Form --}}
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
@@ -82,8 +82,8 @@
</div>
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button>
<button type="button" x-on:click="formOpen = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</button>
<x-btn variante="primary" type="submit">Assegna</x-btn>
<x-btn variante="secondary" x-on:click="formOpen = false">Annulla</x-btn>
</div>
</form>
</div>
@@ -151,27 +151,29 @@
@elseif($pdfUrl)
<div x-data="{ copied: false }" class="mt-3 flex flex-wrap items-center gap-2">
<code class="flex-1 min-w-0 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-gray-600 break-all select-all cursor-text" x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<button type="button"
x-on:click="navigator.clipboard.writeText($el.closest('div').querySelector('code').textContent).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
class="flex-none btn-action btn-action-indigo" title="Copia link" style="padding:6px 10px">
<svg x-show="!copied" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="flex-none"
x-on:click="navigator.clipboard.writeText($el.closest('div').querySelector('code').textContent).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
title="Copia link" aria-label="Copia il link del PDF">
<svg x-show="!copied" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
<svg x-show="copied" x-cloak class="text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</x-btn>
{{-- Flag Link Inviato --}}
<button type="button"
wire:click="toggleLinkSent({{ $a->id }})"
title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}"
class="flex-none inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold border transition
{{ $a->link_sent ? 'bg-green-50 border-green-200 text-green-700 hover:bg-green-100' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100' }}">
{{-- Interruttore di stato, non un comando: lo stato "inviato"
si legge dalla variante (primary quando è acceso) e
aria-pressed lo dice anche a chi non lo vede. --}}
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" class="flex-none"
wire:click="toggleLinkSent({{ $a->id }})"
aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}"
title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}">
@if($a->link_sent)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Link inviato
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
Non inviato
@endif
</button>
</x-btn>
<span x-show="copied" x-cloak class="flex-none text-xs text-green-600 font-medium">Copiato!</span>
</div>

View File

@@ -54,9 +54,9 @@
</div>
@endif
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition">Registra Rientro</button>
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
<div class="flex flex-wrap items-center gap-3 pt-4">
<x-btn variante="primary" type="submit">Registra rientro</x-btn>
<x-btn variante="secondary" :href="route('territori.show', $assegnazione->territorio)">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -27,9 +27,6 @@
<label for="remember" class="ml-2 text-sm text-gray-600">Ricordami</label>
</div>
<button type="submit"
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition" style="background:linear-gradient(135deg,#4338ca,#6366f1)" onmouseover="this.style.background='linear-gradient(135deg,#3730a3,#4f46e5)'" onmouseout="this.style.background='linear-gradient(135deg,#4338ca,#6366f1)'">
<span>Accedi</span>
</button>
<x-btn variante="primary" type="submit" class="w-full">Accedi</x-btn>
</form>
</div>

View File

@@ -29,8 +29,8 @@
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">{{ $btnLabel }}</button>
<a href="{{ route('campagne.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
<x-btn variante="secondary" :href="route('campagne.index')">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -2,10 +2,10 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900">Campagne</h1>
@can('settings.manage')
<a href="{{ route('campagne.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova Campagna
</a>
<x-btn variante="primary" :href="route('campagne.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova campagna
</x-btn>
@endcan
</div>
@@ -47,16 +47,23 @@
</td>
<td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1">
<a href="{{ route('campagne.show', $campagna) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('campagne.show', $campagna)"
title="Dettaglio" aria-label="Dettaglio della campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn>
@can('settings.manage')
<a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</a>
<button wire:click="deleteCampagna({{ $campagna->id }})" wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('campagne.edit', $campagna)"
title="Modifica" aria-label="Modifica la campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteCampagna({{ $campagna->id }})"
wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?"
title="Elimina" aria-label="Elimina la campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
@endcan
</div>
</td>

View File

@@ -8,10 +8,10 @@
<h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1>
</div>
@can('settings.manage')
<a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<x-btn variante="secondary" :href="route('campagne.edit', $campagna)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Modifica
</a>
</x-btn>
@endcan
</div>

View File

@@ -91,9 +91,11 @@
<p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaAssegnare" title="Scarica PDF" class="ml-auto btn-action btn-action-green" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto"
wire:click="downloadPdfDaAssegnare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da assegnare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</x-btn>
@endhasanyrole
</div>
<ul class="divide-y divide-gray-100">
@@ -114,7 +116,7 @@
</p>
</div>
@can('territori.assign')
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="btn-action btn-primary-green">Assegna →</a>
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id])">Assegna →</x-btn>
@endcan
</li>
@empty
@@ -142,9 +144,13 @@
<p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaRientrare" title="Scarica PDF" class="ml-auto btn-action btn-action-red" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</button>
{{-- Scaricare un elenco non è distruttivo: era rosso solo perché
lo è la scheda che lo contiene. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto"
wire:click="downloadPdfDaRientrare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da rientrare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
</x-btn>
@endhasanyrole
</div>
<ul class="divide-y divide-gray-100">
@@ -160,7 +166,7 @@
</div>
@can('territori.return')
@if($assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">Rientra →</a>
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id])">Rientra →</x-btn>
@endif
@endcan
</li>

View File

@@ -25,8 +25,8 @@
<td class="px-4 py-3 text-sm text-right cell-actions">
@can('settings.manage')
<div class="flex flex-wrap items-center justify-end gap-1">
<button wire:click="restore({{ $proclamatore->id }})" class="btn-action btn-action-green">Ripristina</button>
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
<x-btn variante="primary" tono="tenue" dimensione="sm" wire:click="restore({{ $proclamatore->id }})">Ripristina</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile.">Elimina</x-btn>
</div>
@endcan
</td>

View File

@@ -27,8 +27,8 @@
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">{{ $btnLabel }}</button>
<a href="{{ route('proclamatori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
<x-btn variante="secondary" :href="route('proclamatori.index')">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -2,27 +2,31 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900">Proclamatori</h1>
@can('settings.manage')
<a href="{{ route('proclamatori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo Proclamatore
</a>
{{-- Il cestino sale nella testata, com'è nei Territori: prima era un
link rosso perso in mezzo ai filtri, in un posto diverso rispetto
all'altra lista che fa esattamente la stessa cosa. --}}
<div class="flex flex-wrap items-center gap-2">
<x-btn variante="primary" :href="route('proclamatori.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo proclamatore
</x-btn>
<x-btn variante="secondary" :href="route('proclamatori.cestino')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Cestino
</x-btn>
</div>
@endcan
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca nome o cognome..." class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<select wire:model.live="filtroStato" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti</option>
<option value="attivo">Attivi</option>
<option value="inattivo">Inattivi</option>
</select>
<div class="text-xs text-gray-500 flex items-center">
@can('settings.manage')
<a href="{{ route('proclamatori.cestino') }}" class="text-red-500 hover:text-red-700">🗑 Cestino</a>
@endcan
</div>
</div>
</div>
@@ -58,23 +62,33 @@
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
<td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1">
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('proclamatori.show', $proclamatore)"
title="Dettaglio" aria-label="Dettaglio del proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn>
@can('settings.manage')
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</a>
<button wire:click="toggleActive({{ $proclamatore->id }})" class="btn-action btn-action-amber" title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}">
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('proclamatori.edit', $proclamatore)"
title="Modifica" aria-label="Modifica il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="toggleActive({{ $proclamatore->id }})"
title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}"
aria-label="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }} il proclamatore">
@if($proclamatore->attivo)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif
</button>
<button wire:click="deleteProclamatore({{ $proclamatore->id }})" wire:confirm="Spostare il proclamatore nel cestino?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteProclamatore({{ $proclamatore->id }})"
wire:confirm="Spostare il proclamatore nel cestino?"
title="Elimina" aria-label="Sposta nel cestino il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
@endcan
</div>
</td>

View File

@@ -8,10 +8,10 @@
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
</div>
@can('settings.manage')
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<x-btn variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Modifica
</a>
</x-btn>
@endcan
</div>

View File

@@ -48,9 +48,7 @@
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
Salva modifiche
</button>
<x-btn variante="primary" type="submit">Salva modifiche</x-btn>
</div>
</form>
</div>
@@ -84,9 +82,7 @@
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
Aggiorna password
</button>
<x-btn variante="primary" type="submit">Aggiorna password</x-btn>
</div>
</form>
</div>

View File

@@ -3,16 +3,15 @@
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
@can('settings.manage')
<div class="flex flex-wrap items-center gap-2">
<button wire:click="openS13"
class="btn-action btn-action-green" style="padding:8px 18px;font-size:14px;border-radius:8px"
title="Scarica il modulo S-13 compilato con le ultime 4 assegnazioni di un anno di servizio">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Modulo S-13
</button>
<button wire:click="openCreate" class="btn-action btn-action-indigo" style="padding:8px 18px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" wire:click="openCreate">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova voce
</button>
</x-btn>
<x-btn variante="secondary" wire:click="openS13"
title="Scarica il modulo S-13 compilato con le ultime 4 assegnazioni di un anno di servizio">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Modulo S-13
</x-btn>
</div>
@endcan
</div>
@@ -110,21 +109,22 @@
<td class="px-3 py-2 whitespace-nowrap cell-actions">
<div class="flex items-center gap-1 flex-wrap">
@if($temporaryPdfUrl)
<a href="{{ $temporaryPdfUrl }}"
target="_blank"
rel="noopener noreferrer"
class="btn-action btn-action-indigo" title="PDF">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="$temporaryPdfUrl" target="_blank" rel="noopener noreferrer"
title="PDF" aria-label="Apri il PDF dell assegnazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</x-btn>
@endif
<button wire:click="openEdit({{ $a->id }})"
class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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 wire:click="askDelete({{ $a->id }})"
class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="openEdit({{ $a->id }})"
title="Modifica" aria-label="Modifica l assegnazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="askDelete({{ $a->id }})"
title="Elimina" aria-label="Elimina l assegnazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
</div>
</td>
@endcan
@@ -162,18 +162,14 @@
</select>
@error('s13Anno') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<button wire:click="$set('showS13Modal', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="downloadS13"
wire:loading.attr="disabled"
wire:target="downloadS13"
style="background:#16a34a;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
{{-- Azione principale prima, uscita dopo: stesso ordine dei form
e delle altre modali dell'applicazione. --}}
<div class="flex flex-wrap justify-end gap-2" style="margin-top:20px;">
<x-btn variante="primary" wire:click="downloadS13" wire:loading.attr="disabled" wire:target="downloadS13">
<span wire:loading.remove wire:target="downloadS13">Scarica PDF</span>
<span wire:loading wire:target="downloadS13">Generazione…</span>
</button>
</x-btn>
<x-btn variante="ghost" wire:click="$set('showS13Modal', false)">Annulla</x-btn>
</div>
</div>
</div>
@@ -265,15 +261,9 @@
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<button wire:click="$set('showModal', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="save"
style="background:#4f46e5;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
{{ $editingId ? 'Salva modifiche' : 'Crea voce' }}
</button>
<div class="flex flex-wrap justify-end gap-2" style="margin-top:20px;">
<x-btn variante="primary" wire:click="save">{{ $editingId ? 'Salva modifiche' : 'Crea voce' }}</x-btn>
<x-btn variante="ghost" wire:click="$set('showModal', false)">Annulla</x-btn>
</div>
</div>
</div>
@@ -285,15 +275,9 @@
<div style="background:#fff;border-radius:12px;width:100%;max-width:360px;padding:24px;">
<h2 style="font-size:17px;font-weight:700;color:#111827;margin-bottom:10px;">Elimina voce</h2>
<p style="font-size:14px;color:#6b7280;margin-bottom:20px;">Sei sicuro di voler eliminare questa assegnazione? L'operazione non può essere annullata.</p>
<div style="display:flex;justify-content:flex-end;gap:10px;">
<button wire:click="$set('showDeleteConfirm', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="deleteConfirmed"
style="background:#dc2626;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Elimina
</button>
<div class="flex flex-wrap justify-end gap-2">
<x-btn variante="danger" wire:click="deleteConfirmed">Elimina</x-btn>
<x-btn variante="ghost" wire:click="$set('showDeleteConfirm', false)">Annulla</x-btn>
</div>
</div>
</div>

View File

@@ -1,85 +1,52 @@
{{--
Backup, ripristino e azzeramento, con avanzamento reale.
Zone 1 e 2 della pagina: creazione del pacchetto e ripristino.
La barra legge processed/total dell'operazione in corso, che i job
aggiornano quando qualcosa e' davvero finito sul disco. Il poll resta
attivo solo mentre l'operazione e' in coda o in esecuzione: appena chiude,
si ferma da solo.
Avanzamento e log non stanno piu' qui: vanno alla console unica agganciata in
fondo alla finestra (partials/console-operazioni.blade.php). In questa
sezione restano i comandi e l'esito, cioe' le uniche cose che riguardano
*questa* zona; prima invece la pagina aveva un pannello di avanzamento in
cima, una console a meta' e un "Dettaglio" richiudibile, e la stessa
operazione si raccontava in tre posti diversi.
Il JavaScript del caricamento sta ancora dentro l'attributo x-data, quindi
valgono le regole di sempre: dentro quel blocco NON possono comparire
virgolette doppie, da nessuna parte, neanche nei commenti — un attributo HTML
finisce alla prima, e questa pagina si e' gia' rotta cosi' due volte. Gli
apostrofi italiani stanno nei backtick, dove non richiedono accorgimenti: la
sequenza \' scritta a mano nel sorgente Blade chiude la stringa invece di
proteggere l'apostrofo.
--}}
@php
$op = $operazione;
$tipo = $op['tipo'] ?? null;
$stato = $op['status'] ?? null;
$attesaDownload = ($op['fase'] ?? null) === \App\Services\BackupOperationState::FASE_ATTESA_DOWNLOAD;
$tipo = $op['tipo'] ?? null;
$etichettaTipo = match ($tipo) {
'backup' => 'Backup in corso',
'restore' => 'Ripristino in corso',
'reset' => 'Azzeramento in corso',
default => 'Operazione in corso',
};
// "Il posto e' occupato da qualcun altro": i pulsanti si spengono, ma il
// messaggio deve dire quale operazione sta lavorando, non un generico
// divieto.
$occupato = $this->inCorso;
@endphp
{{--
ATTENZIONE, prima di toccare l'x-data qui sotto.
Un attributo HTML finisce alla PRIMA virgoletta doppia, comprese quelle
dentro un commento JavaScript. Un banale // un generico "errore" ha gia'
chiuso l'attributo a meta' e riversato tutto il codice rimanente a video
come testo, due volte. Dentro questo blocco NON possono comparire
virgolette doppie, da nessuna parte, neanche nei commenti.
Per lo stesso motivo le stringhe usano apici o backtick, e gli apostrofi
italiani stanno nei backtick, dove non richiedono alcun accorgimento: la
sequenza \' scritta a mano nel sorgente Blade chiude la stringa invece di
proteggere l'apostrofo, ed e' l'altro modo in cui questo blocco si e' gia'
rotto.
Un blocco <script> con @script eviterebbe la questione alla radice, ma
rompe il rilevamento della radice singola di Livewire (errore 500).
--}}
<div class="space-y-6"
x-data="{
{{-- maxUploadKb() e' in kilobyte; file.size e' in byte. --}}
maxByte: {{ \App\Http\Controllers\Settings\BackupController::maxUploadKb() }} * 1000,
maxByte: {{ \App\Http\Controllers\Settings\BackupController::maxUploadKb() }} * 1000,
zipSelezionato: '',
uploadInCorso: false,
uploadPercento: 0,
erroreUpload: '',
passwordAzzeramento: '',
/*
* Diario di quel che succede nel browser.
*
* Serve perche' i modi in cui un caricamento muore sono spesso muti: il
* server chiude la connessione e nessun evento porta un messaggio, oppure
* la barra smette di avanzare senza che nulla lo dichiari. Qui ogni
* passaggio lascia una riga con l'ora, cosi' il punto esatto in cui si
* ferma e' leggibile invece che da indovinare, e il testo si puo' copiare
* e mandare a chi assiste.
*/
console: [],
ultimoAvanzamento: 0,
battito: null,
log(riga) {
const ora = new Date().toTimeString().slice(0, 8);
this.console.push(`[${ora}] ${riga}`);
// Il pannello e' alto qualche riga: il fondo e' la parte che interessa.
this.$nextTick(() => {
const el = this.$refs.consoleBox;
if (el) el.scrollTop = el.scrollHeight;
});
},
get consoleTesto() {
return this.console.join('\n');
{{-- Ogni riga passa dalla console unica: qui non si tiene piu' un diario
locale, cosi' l'ordine dei fatti resta uno solo. --}}
log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'ripristino' });
},
/*
* Un caricamento fermo non genera eventi: senza qualcosa che batta il
* tempo, l'ultima riga resterebbe quella di dieci minuti prima e non si
* distinguerebbe 'lento' da 'morto'.
* distinguerebbe lento da morto.
*/
avviaBattito() {
this.fermaBattito();
@@ -87,7 +54,7 @@
this.battito = setInterval(() => {
const fermoDa = Math.round((Date.now() - this.ultimoAvanzamento) / 1000);
if (fermoDa >= 15) {
this.log(`...nessun avanzamento da ${fermoDa}s (${this.uploadPercento}%)`);
this.log(`...nessun avanzamento da ${fermoDa}s (${this.uploadPercento}%)`, 'attesa');
}
}, 15000);
},
@@ -99,23 +66,7 @@
}
},
azzera() {
if (!this.passwordAzzeramento) {
return;
}
if (!confirm('Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente.')) {
return;
}
const password = this.passwordAzzeramento;
// Svuotata subito: se e' sbagliata si ridigita, e intanto non resta
// nel campo di una scheda lasciata aperta.
this.passwordAzzeramento = '';
this.$wire.confermaAzzeramento(password);
},
/**
/*
* Superato il tetto, e' nginx a chiudere la connessione a meta'
* trasferimento: la barra si pianta a una percentuale qualsiasi e non
* arriva nessun evento utile, quindi la pagina resta muta con l'aria di
@@ -153,8 +104,10 @@
this.log(`Limite del server: ${(this.maxByte / 1000000).toFixed(0)} MB`);
if (this.zipTroppoGrande(file)) {
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`);
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`, 'errore');
}
window.tmFocusZona('zona-ripristino', false);
},
inviaRipristino(event) {
@@ -162,7 +115,7 @@
const file = form.querySelector('input[name=backupZip]').files[0];
if (this.zipTroppoGrande(file)) {
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`);
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`, 'errore');
return;
}
@@ -170,6 +123,7 @@
this.uploadInCorso = true;
this.uploadPercento = 0;
this.$dispatch('console-apri');
this.log(`Invio a ${form.action}`);
const xhr = new XMLHttpRequest();
@@ -201,7 +155,7 @@
});
xhr.upload.addEventListener('load', () => {
this.log(`Corpo della richiesta inviato per intero. In attesa della risposta del server...`);
this.log(`Corpo della richiesta inviato per intero. In attesa della risposta del server...`, 'attesa');
});
xhr.addEventListener('load', () => {
@@ -214,14 +168,14 @@
payload = JSON.parse(xhr.responseText || '{}');
} catch (e) {
payload = {};
this.log(`La risposta non e' JSON. Inizio: ${xhr.responseText.slice(0, 120)}`);
this.log(`La risposta non è JSON. Inizio: ${xhr.responseText.slice(0, 120)}`, 'errore');
}
if (xhr.status >= 200 && xhr.status < 300 && payload.operation_id) {
this.uploadPercento = 100;
this.zipSelezionato = '';
form.reset();
this.log(`Ripristino accodato: operazione ${payload.operation_id}`);
this.log(`Ripristino accodato: operazione ${payload.operation_id}`, 'ok');
this.log(`Da qui l'avanzamento arriva dal server.`);
// Da qui in poi l'avanzamento arriva dal job, non dall'upload.
this.$wire.seguiOperazione(payload.operation_id);
@@ -232,7 +186,7 @@
// il ripristino sia partito.
this.uploadPercento = 0;
this.erroreUpload = this.messaggioErrore(xhr.status, payload);
this.log(`ERRORE: ${this.erroreUpload}`);
this.log(`ERRORE: ${this.erroreUpload}`, 'errore');
});
xhr.addEventListener('error', () => {
@@ -241,7 +195,7 @@
this.fermaBattito();
this.erroreUpload = `Errore di rete durante il caricamento: connessione interrotta dal `
+ `server. Succede tipicamente quando il pacchetto supera il limite accettato.`;
this.log(`ERRORE DI RETE: il server ha chiuso la connessione senza rispondere.`);
this.log(`ERRORE DI RETE: il server ha chiuso la connessione senza rispondere.`, 'errore');
});
xhr.addEventListener('timeout', () => {
@@ -249,7 +203,7 @@
this.uploadPercento = 0;
this.fermaBattito();
this.erroreUpload = `Il caricamento non ha risposto entro 5 minuti ed è stato interrotto.`;
this.log(`TIMEOUT dopo 5 minuti senza risposta.`);
this.log(`TIMEOUT dopo 5 minuti senza risposta.`, 'errore');
});
xhr.addEventListener('abort', () => {
@@ -257,14 +211,14 @@
this.uploadPercento = 0;
this.fermaBattito();
this.erroreUpload = `Caricamento annullato.`;
this.log(`Caricamento annullato.`);
this.log(`Caricamento annullato.`, 'errore');
});
this.avviaBattito();
xhr.send(new FormData(form));
},
/**
/*
* 413 e 419 non hanno un corpo JSON: senza il codice HTTP nel messaggio
* resterebbe un generico errore che non dice cosa fare.
*/
@@ -292,136 +246,30 @@
return `Errore durante il caricamento del pacchetto (HTTP ${status}).`;
},
}"
x-on:scarica-backup.window="window.location.href = $event.detail.url">
{{-- Il poll vive quanto l'operazione: a stato `completed`/`failed` inCorso
diventa falso, l'attributo sparisce dal render successivo e le
richieste si fermano da sole. Vale anche quando l'operazione è di
un'altra zona: qui si polla piano (3s) solo per accorgersi che il
posto si è liberato. --}}
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
@if($erroreOperazione !== '')
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900" role="alert">
{{ $erroreOperazione }}
</div>
@endif
{{-- ─── Pannello di avanzamento ────────────────────────────────── --}}
@if($operationId && $op)
<div class="rounded-xl border p-5
{{ $stato === 'failed' ? 'border-red-300 bg-red-50' : ($stato === 'completed' ? 'border-green-300 bg-green-50' : 'border-indigo-200 bg-indigo-50') }}"
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900">
{{ $stato === 'completed' ? 'Operazione completata' : ($stato === 'failed' ? 'Operazione fallita' : $etichettaTipo) }}
</div>
<div class="mt-0.5 text-xs text-gray-600 truncate">
{{ $op['fase'] ?? '' }}
@if(($op['total'] ?? 0) > 0 && $stato !== 'completed')
&mdash; {{ $op['processed'] ?? 0 }} / {{ $op['total'] }}
@endif
</div>
</div>
<div class="text-2xl font-bold tabular-nums {{ $stato === 'failed' ? 'text-red-700' : 'text-indigo-700' }}">
{{ $this->percentuale }}%
</div>
</div>
<div class="mt-3 h-2.5 overflow-hidden rounded-full bg-white/70">
<div class="h-full rounded-full transition-all duration-300
{{ $stato === 'failed' ? 'bg-red-600' : ($stato === 'completed' ? 'bg-green-600' : 'bg-indigo-600') }}"
style="width: {{ $this->percentuale }}%"></div>
</div>
@if($attesaDownload)
<div class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-4 py-3 space-y-3">
<p class="text-sm text-amber-900">
<strong>L&rsquo;archivio &egrave; pronto e il download &egrave; partito.</strong>
Non &egrave; stato cancellato ancora nulla: l&rsquo;azzeramento aspetta la tua conferma.
</p>
<p class="text-xs text-amber-800">
Apri il file scaricato e verifica che sia integro. Il server sa di aver <em>spedito</em>
l&rsquo;archivio, non pu&ograve; sapere se &egrave; arrivato intero: quella verifica tocca a te,
ed &egrave; l&rsquo;ultima possibilit&agrave; di tornare indietro.
</p>
{{-- La password sta in Alpine, non in una proprietà del
componente: le proprietà pubbliche di Livewire tornano
al browser dentro lo snapshot a ogni risposta, e una
password non ha motivo di fare quel viaggio. Passata
come parametro del metodo, viaggia solo in andata. --}}
<div class="max-w-xs">
<label for="passwordConferma" class="block text-xs font-medium text-amber-900">
Conferma con la tua password
</label>
<input type="password" id="passwordConferma" autocomplete="current-password"
x-model="passwordAzzeramento"
x-on:keydown.enter.prevent="azzera()"
class="mt-1 block w-full rounded-lg border-amber-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm">
@error('passwordConferma') <p class="mt-1 text-xs font-medium text-red-700">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-center gap-3">
<a href="{{ route('backup.file', $operationId) }}"
style="display:inline-flex;align-items:center;gap:6px;background:#fff;color:#92400e;border:1px solid #d97706;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
Scarica di nuovo
</a>
<button type="button"
x-on:click="azzera()"
x-bind:disabled="!passwordAzzeramento"
x-bind:style="!passwordAzzeramento ? 'opacity:.5;cursor:not-allowed;' : ''"
style="display:inline-flex;align-items:center;gap:6px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;">
Ho salvato il backup, azzera tutto
</button>
<button type="button" wire:click="annullaAzzeramento" class="text-xs text-gray-700 underline">
Annulla l&rsquo;azzeramento
</button>
</div>
@error('confermaReset') <p class="text-xs font-medium text-red-700">{{ $message }}</p> @enderror
</div>
@endif
@if($stato === 'failed')
<p class="mt-3 text-sm text-red-800">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && ($op['result']['messaggio'] ?? null))
<p class="mt-3 text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['file'] ?? null))
<div class="mt-3 flex flex-wrap items-center gap-3">
<a href="{{ route('backup.file', $operationId) }}"
style="display:inline-flex;align-items:center;gap:6px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
Scarica di nuovo
</a>
<span class="text-xs text-gray-600">
{{ $op['result']['nome_download'] ?? '' }} &mdash;
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
</span>
</div>
@endif
@if(!empty($op['logs']))
<details class="mt-3">
<summary class="cursor-pointer text-xs font-medium text-gray-700">Dettaglio</summary>
<textarea readonly rows="8" class="mt-2 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ implode(PHP_EOL, $op['logs']) }}</textarea>
</details>
@endif
@if(in_array($stato, ['completed', 'failed'], true))
<button type="button" wire:click="chiudi" class="mt-3 text-xs text-gray-600 underline">Chiudi</button>
@endif
</div>
@endif
{{-- ─── Backup ─────────────────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
{{-- ═══ Zona 1 — Backup ═══════════════════════════════════════════ --}}
<section id="zona-backup"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5
{{ $occupato && $tipo === 'backup' ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-backup">
<div>
<h2 class="text-lg font-semibold text-gray-900">Backup completo (.zip)</h2>
<h2 id="titolo-backup" class="text-lg font-semibold text-gray-900">1 &middot; Backup completo (.zip)</h2>
<p class="text-xs text-gray-500 mt-1">
Un unico pacchetto con <strong>tutti</strong> i dati dell&rsquo;applicazione &mdash; utenti compresi &mdash;
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
E&rsquo; l&rsquo;archivio da conservare per rimettere in piedi l&rsquo;installazione da zero.
&Egrave; l&rsquo;archivio da conservare per rimettere in piedi l&rsquo;installazione da zero.
</p>
</div>
@@ -430,21 +278,55 @@
proclamatori in chiaro e le mappe dei territori. Conservalo in un luogo sicuro e non condividerlo.
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button type="button" wire:click="avviaBackup" @disabled($this->inCorso)
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="primary"
wire:click="avviaBackup"
x-on:click="$dispatch('console-apri')"
data-zona-focus
{{-- :disabled e non @disabled: dentro un tag <x-...> le
direttive Blade non vengono compilate, il tag resta
letterale e il PHP prodotto va in errore di sintassi. --}}
:disabled="$occupato">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Crea e scarica backup
</button>
<p class="text-xs text-gray-500">L&rsquo;archivio si genera in background: puoi seguire l&rsquo;avanzamento qui sopra.</p>
</div>
</div>
</x-btn>
{{-- ─── Ripristino ─────────────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
{{-- L'archivio resta scaricabile finche' l'operazione e' in vista:
un download interrotto non deve costringere a rifare tutto. --}}
@if($this->archivioPronto && $tipo === 'backup')
<x-btn variante="secondary" :href="route('backup.file', $operationId)">
Scarica di nuovo
</x-btn>
<span class="text-xs text-gray-600">
{{ $op['result']['nome_download'] ?? '' }} &mdash;
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
</span>
@endif
@if($occupato)
<span class="text-xs text-gray-500">
Un&rsquo;operazione &egrave; gi&agrave; in corso: l&rsquo;avanzamento &egrave; nella console in fondo.
</span>
@else
<span class="text-xs text-gray-500">L&rsquo;archivio si genera in background e compare nella console in fondo.</span>
@endif
</div>
@if($stato === 'failed' && $tipo === 'backup')
<p class="text-sm text-red-800" role="alert">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['messaggio'] ?? null))
<p class="text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
</section>
{{-- ═══ Zona 2 — Ripristino ═══════════════════════════════════════ --}}
<section id="zona-ripristino"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4
{{ $occupato && $tipo === 'restore' ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-ripristino">
<div>
<h2 class="text-lg font-semibold text-gray-900">Ripristina da backup</h2>
<h2 id="titolo-ripristino" class="text-lg font-semibold text-gray-900">2 &middot; Ripristina da backup</h2>
<p class="text-xs text-gray-500 mt-1">
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
gestionali correnti e tutti i file caricati.
@@ -453,7 +335,7 @@
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-xs text-red-800">
<strong>Al termine tutti dovranno cambiare password</strong>, amministratore compreso: il pacchetto
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono piu&rsquo; da
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono pi&ugrave; da
considerarsi segrete. Verrai portato subito sulla schermata di cambio password.
</div>
@@ -464,16 +346,21 @@
{{-- La dimensione si controlla gia' alla selezione: sapere
subito che il file e' fuori limite evita di aspettare un
caricamento destinato a essere tagliato. --}}
<input name="backupZip" type="file" accept=".zip,application/zip"
<label for="backupZip" class="block text-xs font-medium text-gray-700 mb-1">Pacchetto di backup</label>
<input id="backupZip" name="backupZip" type="file" accept=".zip,application/zip"
data-zona-focus
x-on:change="fileScelto($event)"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
<p class="mt-1 text-xs text-gray-500">
Dimensione massima accettata: {{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB.
Dimensione massima accettata: {{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB
(variabile <code class="rounded bg-gray-100 px-1">MAX_UPLOAD_MB</code>).
</p>
<p x-show="erroreUpload" x-cloak class="text-red-600 text-xs mt-1 font-medium" x-text="erroreUpload"></p>
<p x-show="erroreUpload" x-cloak class="text-red-600 text-xs mt-1 font-medium" role="alert" x-text="erroreUpload"></p>
</div>
{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}
{{-- Caricamento: percentuale reale dell'upload, non del ripristino.
Resta qui e non nella console perche' e' l'unica fase che avviene
nel browser: la console racconta, questa barra misura. --}}
<div x-show="uploadInCorso || uploadPercento > 0" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploadInCorso ? 'Caricamento del pacchetto...' : 'Pacchetto caricato'"></span>
@@ -484,100 +371,22 @@
</div>
</div>
@php($bloccato = $this->inCorso)
<button type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#dc2626;color:#fff;border:1px solid #991b1b;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
x-bind:style="(!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}) ? 'opacity:.5;cursor:not-allowed;' : ''">
Ripristina backup
</button>
@if($bloccato)
<p class="text-xs text-gray-500">Un&rsquo;altra operazione è in corso: attendi che finisca.</p>
@endif
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="danger" type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso || {{ $occupato ? 'true' : 'false' }}">
Ripristina backup
</x-btn>
@if($occupato)
<span class="text-xs text-gray-500">Un&rsquo;altra operazione &egrave; in corso: attendi che finisca.</span>
@endif
</div>
</form>
{{-- Console: il diario del browser e quello del server, uno sotto
l'altro. E' l'unico posto da cui si capisce dove un caricamento si
e' fermato, e il testo si seleziona e si copia per mandarlo a chi
assiste. --}}
<div class="border-t border-gray-200 pt-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900">Console</h3>
<div class="flex items-center gap-3 text-xs">
<button type="button" x-on:click="console = []" class="text-gray-600 underline">Pulisci</button>
<button type="button"
x-on:click="navigator.clipboard.writeText(consoleTesto + String.fromCharCode(10) + $refs.logServer?.value)"
class="text-gray-600 underline">Copia tutto</button>
</div>
</div>
<div class="mt-2">
<div class="text-xs font-medium text-gray-700">Browser</div>
<textarea readonly rows="8" x-ref="consoleBox" x-text="consoleTesto || 'In attesa. Scegli un pacchetto per iniziare.'"
class="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100"></textarea>
</div>
<div class="mt-3">
<div class="text-xs font-medium text-gray-700">Server</div>
<textarea readonly rows="8" x-ref="logServer"
class="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ !empty($op['logs']) ? implode(PHP_EOL, $op['logs']) : 'Nessuna operazione sul server. Compare qui appena il pacchetto viene accettato.' }}</textarea>
</div>
<p class="mt-2 text-xs text-gray-500">
Limite di caricamento configurato:
<strong>{{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB</strong>
(variabile <code class="rounded bg-gray-100 px-1">MAX_UPLOAD_MB</code>).
</p>
</div>
</div>
{{-- ─── Zona pericolosa ────────────────────────────────────────── --}}
<div class="rounded-xl border-2 border-red-300 bg-red-50/40 p-6 space-y-4">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 flex-none text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
<div>
<h2 class="text-lg font-semibold text-red-900">Zona pericolosa</h2>
<p class="text-xs text-red-800 mt-1">Operazioni irreversibili. Leggi prima di premere.</p>
</div>
</div>
<div class="rounded-lg border border-red-300 bg-white p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Azzera tutto e riparti da zero</h3>
<p class="text-xs text-gray-600">
Riporta l&rsquo;applicazione allo stato di installazione appena eseguita. In ordine:
</p>
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ciò che c&rsquo;è adesso.</li>
<li>L&rsquo;operazione si ferma e aspetta: <strong>controlli il file scaricato, digiti la tua password e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e <strong>tutti i file caricati</strong>.</li>
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale &mdash; come un&rsquo;installazione nuova, che ha un amministratore solo.</li>
</ol>
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
Il backup scaricato è l&rsquo;<strong>unico</strong> modo per tornare indietro. Verifica di averlo
salvato prima di chiudere il browser.
</div>
<div>
<label for="confermaReset" class="block text-xs font-medium text-gray-700">
Per confermare, scrivi <code class="rounded bg-gray-100 px-1 py-0.5 font-mono">{{ \App\Livewire\Settings\BackupManager::CONFERMA_AZZERAMENTO }}</code>
</label>
<input wire:model="confermaReset" type="text" id="confermaReset" autocomplete="off"
class="mt-1 block w-full max-w-xs rounded-lg border-gray-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm font-mono">
@error('confermaReset') <p class="text-red-600 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="button"
wire:click="avviaAzzeramento"
wire:confirm="Ultima conferma. Verrà scaricato un backup e poi l'applicazione sarà azzerata. L'operazione non è reversibile."
@disabled($this->inCorso)
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:600;"
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.87 12.14A2 2 0 0116.14 21H7.86a2 2 0 01-1.99-1.86L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3M4 7h16"/></svg>
Scarica backup e azzera tutto
</button>
</div>
</div>
@if($stato === 'failed' && $tipo === 'restore')
<p class="text-sm text-red-800" role="alert">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && $tipo === 'restore' && ($op['result']['messaggio'] ?? null))
<p class="text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
</section>
</div>

View File

@@ -0,0 +1,345 @@
{{--
Console unica delle operazioni: backup, ripristino, azzeramento, import PDF,
import XML. Agganciata in fondo alla finestra, si apre da sé quando qualcosa
parte e scorre da sola mentre arrivano le righe.
Perché una sola. Prima questa pagina aveva cinque riquadri di log — due nel
backup (browser e server), uno per l'upload dei PDF, uno per lo stato
dell'import, più il "Dettaglio" del pannello di avanzamento — e nessuno
diceva la storia per intero: un ripristino cominciava nel riquadro del
browser e finiva in quello del server, in due punti diversi della pagina, e
per capire dove si era fermato bisognava guardarli tutti. Qui la cronologia è
una, in ordine di tempo, con la sorgente accanto a ogni riga.
Perché è fissa. Quasi tutti i modi in cui un caricamento muore sono muti, e
l'unico indizio è dove si è interrotto il flusso di righe: un log che esce
dallo schermo appena si scorre la pagina non serve a niente proprio nel
momento in cui servirebbe.
Da dove arrivano le righe:
- `console-riga` eventi del browser (scelta file, avanzamento
dell'upload, codici HTTP, timeout, interruzioni)
- `console-operazione` code di log del server, rimandate per intero a ogni
poll da BackupManager e XmlExchange; qui si tiene il
conto di quante sono già state assorbite, così una
riga non compare due volte
--}}
{{--
ATTENZIONE — il JavaScript sta qui, in un <script>, e NON dentro l'attributo
x-data.
Un attributo HTML finisce alla prima virgoletta doppia, comprese quelle
dentro un commento: questa pagina si è già rotta due volte così, riversando
a video il codice come testo. Dentro un <script> le virgolette e gli
apostrofi non hanno alcuna limitazione.
Non è in resources/js/app.js perché quel bundle è un modulo ES, che il
browser rimanda a dopo il parsing del documento, mentre Alpine arriva col
pacchetto di Livewire in fondo al <body> ed è già avviato a quel punto: una
Alpine.data() registrata dopo l'avvio non viene applicata agli elementi già
inizializzati. Uno <script> classico qui viene eseguito prima, sempre.
Non usa @script (la direttiva di Livewire) perché rompe il rilevamento della
radice singola del componente e restituisce 500 — provato.
--}}
<script>
window.tmConsoleOperazioni = function () {
return {
righe: [],
aperta: false,
/* Lo scorrimento automatico si sospende se si torna indietro a
leggere: trascinare l'utente in fondo mentre sta guardando una
riga di dieci secondi prima è il modo più rapido di rendere
inutile un log che scorre. */
inFondo: true,
operazione: { etichetta: '', fase: '', percento: 0, stato: '' },
/* Tetto alla cronologia: un import di qualche migliaio di PDF
riempirebbe la memoria della scheda con nodi che nessuno legge. */
maxRighe: 600,
init() {
this.log('Console pronta: le operazioni compaiono qui.', 'info', 'app');
},
get haOperazione() {
return this.operazione.etichetta !== '';
},
get etichettaStato() {
if (this.operazione.stato === 'failed') return 'fallita';
if (this.operazione.stato === 'completed') return 'completata';
return 'in corso';
},
apri() {
if (this.aperta) return;
this.aperta = true;
this.inFondo = true;
this.$nextTick(() => this.scorriInFondo());
},
chiudi() {
this.aperta = false;
},
commuta() {
this.aperta ? this.chiudi() : this.apri();
},
log(testo, livello, sorgente, ora) {
this.righe.push({
ora: ora || new Date().toTimeString().slice(0, 8),
testo: testo,
livello: livello || 'info',
sorgente: sorgente || 'browser',
});
if (this.righe.length > this.maxRighe) {
this.righe.splice(0, this.righe.length - this.maxRighe);
}
this.$nextTick(() => this.scorriInFondo());
},
/* Riga singola dal browser. */
aggiungi(dettaglio) {
if (!dettaglio || !dettaglio.testo) return;
this.apri();
this.log(dettaglio.testo, dettaglio.livello, dettaglio.sorgente || 'browser');
},
/* Righe nuove dal server, più lo stato dell'operazione.
Sono già solo le nuove: il conto di quante sono state spedite lo
tiene il componente Livewire (SegueOperazioneBackup::logSpediti),
così la coda intera non viaggia dentro ogni risposta — sessanta
righe, poco meno di due volte al secondo. Per ogni operazione
esiste un solo componente che spedisce, quindi qui non serve
deduplicare. */
assorbi(dettaglio) {
if (!dettaglio) return;
const sorgente = dettaglio.sorgente || 'server';
const nuove = Array.isArray(dettaglio.righe) ? dettaglio.righe : [];
if (nuove.length) {
this.apri();
nuove.forEach((riga) => {
this.log(this.testoDi(riga), this.livelloDi(riga), sorgente, this.oraDi(riga));
});
}
if (dettaglio.etichetta !== undefined) {
this.operazione = {
etichetta: dettaglio.etichetta || '',
fase: dettaglio.fase || '',
percento: Number(dettaglio.percento) || 0,
stato: dettaglio.stato || '',
};
if (this.operazione.etichetta) {
this.apri();
}
}
},
/* Le righe del server arrivano già con "[HH:MM:SS] " davanti: quella
è l'ora del server, che è quella giusta da mostrare — l'ora del
browser sarebbe un'altra macchina. */
oraDi(riga) {
const trovato = String(riga).match(/^\[(\d{2}:\d{2}:\d{2})\]/);
return trovato ? trovato[1] : new Date().toTimeString().slice(0, 8);
},
testoDi(riga) {
return String(riga).replace(/^\[\d{2}:\d{2}:\d{2}\]\s*/, '');
},
livelloDi(riga) {
const testo = String(riga);
if (/ERRORE|FALLIT|non riuscit|rifiutat/i.test(testo)) return 'errore';
if (/complet|riuscit|pronto|creato/i.test(testo)) return 'ok';
if (/attesa|in coda/i.test(testo)) return 'attesa';
return 'info';
},
/* Svuota solo la vista: le righe già scritte se ne vanno, quelle che
arriveranno da qui in poi continuano a comparire. Il conto di cosa
è stato spedito lo tiene il server, quindi pulire non fa
ricomparire il passato né interrompe il flusso. */
pulisci() {
this.righe = [];
this.log('Cronologia svuotata.', 'info', 'app');
},
testoIntero() {
return this.righe
.map((r) => '[' + r.ora + '] ' + r.sorgente + ': ' + r.testo)
.join('\n');
},
copia() {
const testo = this.testoIntero();
if (!navigator.clipboard) {
this.log('Gli appunti non sono disponibili su una connessione non sicura: seleziona il testo a mano.', 'errore', 'app');
return;
}
navigator.clipboard.writeText(testo).then(
() => this.log('Cronologia copiata negli appunti (' + this.righe.length + ' righe).', 'ok', 'app'),
() => this.log('Copia negli appunti rifiutata dal browser.', 'errore', 'app')
);
},
scorriInFondo() {
const corpo = this.$refs.corpo;
if (!corpo || !this.inFondo) return;
corpo.scrollTop = corpo.scrollHeight;
},
aggiornaAncoraggio() {
const corpo = this.$refs.corpo;
if (!corpo) return;
/* Tolleranza di 24px: con lo scorrimento fluido la posizione non
arriva mai esattamente al fondo, e senza margine lo
scorrimento automatico si spegnerebbe da solo. */
this.inFondo = (corpo.scrollHeight - corpo.scrollTop - corpo.clientHeight) < 24;
},
riaggancia() {
this.inFondo = true;
this.scorriInFondo();
},
};
};
/**
* Porta la pagina su una zona e la fa lampeggiare.
*
* È il "focus" della pagina: premuto un pulsante, o scelta una voce
* dell'indice, deve essere evidente *dove* è finita l'attenzione, invece di
* dover cercare cosa è cambiato. Il focus da tastiera si sposta solo quando
* la richiesta è di navigazione (l'indice): spostarlo anche a ogni azione
* strapperebbe il cursore da sotto le dita di chi ha appena premuto.
*/
window.tmFocusZona = function (id, spostaFocus) {
const zona = document.getElementById(id);
if (!zona) return;
zona.scrollIntoView({ behavior: 'smooth', block: 'start' });
zona.classList.remove('tm-zona-evidenziata');
/* Forza un reflow: senza, il browser considera la classe già applicata e
su due clic ravvicinati l'animazione non si ripete. */
void zona.offsetWidth;
zona.classList.add('tm-zona-evidenziata');
window.setTimeout(function () {
zona.classList.remove('tm-zona-evidenziata');
}, 1700);
if (spostaFocus) {
const primo = zona.querySelector('[data-zona-focus]');
if (primo) {
primo.focus({ preventScroll: true });
}
}
};
</script>
<div wire:ignore
x-data="tmConsoleOperazioni()"
x-on:console-riga.window="aggiungi($event.detail)"
x-on:console-operazione.window="assorbi($event.detail)"
x-on:console-apri.window="apri()"
{{-- Il download automatico dell'archivio si ascolta QUI e in nessun altro
posto. È un evento su window: due componenti che lo ascoltassero
entrambi assegnerebbero window.location.href due volte, e il browser
scaricherebbe il pacchetto due volte. La console è l'unico elemento
della pagina che c'è sempre, qualunque zona abbia avviato
l'operazione. --}}
x-on:scarica-backup.window="log('Scaricamento del pacchetto avviato.', 'ok', 'app'); window.location.href = $event.detail.url">
{{-- Spaziatore: il pannello è a posizione fissa, quindi senza questo
coprirebbe l'ultima zona della pagina — che è quella pericolosa. --}}
<div aria-hidden="true" x-bind:style="'height:' + (aperta ? 360 : 60) + 'px'"></div>
<section class="tm-console" aria-label="Console delle operazioni">
<div class="tm-console-testata">
<button type="button" class="tm-console-titolo" x-on:click="commuta()"
aria-controls="tm-console-corpo"
x-bind:aria-expanded="aperta ? 'true' : 'false'">
<span aria-hidden="true" x-text="aperta ? '▾' : '▸'"></span>
<span>Console</span>
<span style="color:#9ca3af;font-weight:400"
x-show="!aperta && righe.length > 0" x-cloak
x-text="'(' + righe.length + ')'"></span>
</button>
{{-- Stato dell'operazione. aria-live sta solo qui e non sul corpo:
un log che si aggiorna ogni 750ms letto ad alta voce riga per
riga sarebbe inascoltabile. --}}
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0"
x-show="haOperazione" x-cloak>
<span style="font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
aria-live="polite">
<span style="font-weight:600" x-text="operazione.etichetta"></span>
<span style="color:#9ca3af" x-show="operazione.fase" x-cloak
x-text="' · ' + operazione.fase"></span>
</span>
<div class="tm-console-barra"
x-bind:class="{
'tm-console-barra-ok': operazione.stato === 'completed',
'tm-console-barra-errore': operazione.stato === 'failed'
}"
role="progressbar" aria-label="Avanzamento operazione"
aria-valuemin="0" aria-valuemax="100"
x-bind:aria-valuenow="operazione.percento">
<div x-bind:style="'width:' + operazione.percento + '%'"></div>
</div>
<span style="font-size:12px;font-variant-numeric:tabular-nums;min-width:38px;text-align:right"
x-text="operazione.percento + '%'"></span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-left:auto">
<button type="button" class="tm-console-btn" x-on:click="pulisci()">Pulisci</button>
<button type="button" class="tm-console-btn" x-on:click="copia()">Copia</button>
</div>
</div>
<div id="tm-console-corpo" class="tm-console-corpo" x-ref="corpo"
x-show="aperta" x-cloak
x-on:scroll="aggiornaAncoraggio()">
<template x-for="(riga, indice) in righe" :key="indice">
<div class="tm-console-riga" x-bind:class="'tm-console-riga-' + riga.livello">
<span class="tm-console-ora" x-text="riga.ora"></span>
<span class="tm-console-sorgente" x-text="riga.sorgente"></span>
<span class="tm-console-testo" x-text="riga.testo"></span>
</div>
</template>
</div>
<button type="button" class="tm-console-torna"
x-show="aperta && !inFondo" x-cloak
x-on:click="riaggancia()">
Torna in fondo &darr;
</button>
</section>
</div>

View File

@@ -6,9 +6,9 @@
<div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4 max-w-lg">
<p class="text-sm font-medium text-gray-700 mb-3">Sezione Import</p>
<div class="flex flex-wrap gap-2">
<a href="{{ route('xml.exchange') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
<x-btn variante="primary" :href="route('xml.exchange')">
Apri strumenti di import
</a>
</x-btn>
</div>
<p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p>
</div>
@@ -72,7 +72,7 @@
</div>
<div class="pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Salva Impostazioni</button>
<x-btn variante="primary" type="submit">Salva impostazioni</x-btn>
</div>
</form>
</div>

View File

@@ -11,7 +11,7 @@
<input wire:model="nuovaTipologia" type="text" id="nuovaTipologia" placeholder="Nome tipologia..." class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('nuovaTipologia') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Aggiungi</button>
<x-btn variante="primary" type="submit">Aggiungi</x-btn>
</form>
</div>
@@ -23,8 +23,8 @@
@if($editingId === $tipo->id)
<form wire:submit="saveEdit" class="flex items-center gap-2 flex-1">
<input wire:model="editingNome" type="text" class="flex-1 rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<button type="submit" class="text-green-600 hover:text-green-800 text-xs font-medium">Salva</button>
<button type="button" wire:click="cancelEdit" class="text-gray-500 hover:text-gray-700 text-xs">Annulla</button>
<x-btn variante="primary" dimensione="sm" type="submit">Salva</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="cancelEdit">Annulla</x-btn>
</form>
@else
<div class="flex items-center gap-2">
@@ -35,9 +35,9 @@
<span class="text-xs text-gray-400">{{ $tipo->territori()->count() }} territori</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button wire:click="startEdit({{ $tipo->id }})" class="text-yellow-600 hover:text-yellow-800 text-xs">Rinomina</button>
<button wire:click="toggleActive({{ $tipo->id }})" class="text-xs {{ $tipo->attivo ? 'text-gray-500' : 'text-green-600' }}">{{ $tipo->attivo ? 'Disattiva' : 'Attiva' }}</button>
<button wire:click="deleteTipologia({{ $tipo->id }})" wire:confirm="Eliminare la tipologia '{{ $tipo->nome }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $tipo->id }})">Rinomina</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="toggleActive({{ $tipo->id }})">{{ $tipo->attivo ? 'Disattiva' : 'Attiva' }}</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="deleteTipologia({{ $tipo->id }})" wire:confirm="Eliminare la tipologia '{{ $tipo->nome }}'?">Elimina</x-btn>
</div>
@endif
</li>

View File

@@ -51,7 +51,7 @@
</div>
<div>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Crea Utente</button>
<x-btn variante="primary" type="submit">Crea utente</x-btn>
</div>
</form>
</div>
@@ -106,8 +106,8 @@
</div>
<div class="mt-3 flex gap-2">
<button wire:click="updateUser" type="button" class="px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">Salva</button>
<button wire:click="cancelEdit" type="button" class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">Annulla</button>
<x-btn variante="primary" dimensione="sm" wire:click="updateUser">Salva</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="cancelEdit">Annulla</x-btn>
</div>
</td>
@else
@@ -124,15 +124,14 @@
</td>
<td class="px-4 py-2 cell-actions">
<div class="flex gap-2">
<button wire:click="startEdit({{ $user->id }})" type="button" class="px-2 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 rounded hover:bg-indigo-100">Modifica</button>
<button
wire:click="deleteUser({{ $user->id }})"
onclick="if(!confirm('Confermi la cancellazione dell\'utente? I log verranno preservati.')) event.stopImmediatePropagation();"
type="button"
class="px-2 py-1 text-xs font-medium text-red-700 bg-red-50 rounded hover:bg-red-100"
>
Cancella
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
{{-- wire:confirm come nel resto dell'app, al posto di un onclick
che fermava la propagazione a mano. --}}
<x-btn variante="danger" tono="tenue" dimensione="sm"
wire:click="deleteUser({{ $user->id }})"
wire:confirm="Confermi la cancellazione dell'utente? I log verranno preservati.">
Elimina
</x-btn>
</div>
</td>
@endif

View File

@@ -1,375 +1,429 @@
{{--
Pagina "Backup e import".
Cinque zone numerate, in ordine di quanto sono definitive:
1 Backup completo (BackupManager)
2 Ripristina da backup (BackupManager)
3 Import PDF territori (qui)
4 Dati in formato XML (qui)
5 Zona pericolosa (ZonaPericolosa)
Ogni zona ha un'ancora, e l'indice in cima ci porta sopra evidenziandola:
con cinque riquadri lunghi, arrivare "circa dalle parti giuste" scorrendo
non basta a capire dove si e' atterrati.
Backup, ripristino e azzeramento stanno in componenti a se' perche' hanno
uno stato proprio e un poll fitto che non deve far ri-renderizzare tutta la
pagina — inclusa la tabella degli esiti dell'import, che puo' avere
centinaia di righe.
L'avanzamento e i log di TUTTE le operazioni finiscono in una sola console,
agganciata in fondo alla finestra. Qui dentro non ci sono piu' riquadri di
log: ce n'erano cinque, e la stessa operazione si raccontava in tre punti
diversi della pagina.
--}}
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Backup e import</h1>
<p class="text-sm text-gray-500 mt-1">Backup completo dell&rsquo;applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p>
<p class="text-sm text-gray-500 mt-1">
Backup completo dell&rsquo;applicazione, ripristino, import dei PDF dei territori,
scambio dati in XML e azzeramento.
</p>
</div>
{{-- Backup, ripristino e zona pericolosa: componente a sé perché ha uno
stato proprio (l'operazione in corso) e un poll che non deve far
ri-renderizzare tutto il resto della pagina. Sta in cima perché è la
sezione che serve nel 99% dei casi; le sezioni XML più sotto restano
per gli usi parziali e per la conversione dei dump legacy. --}}
{{-- ─── Indice delle zone ──────────────────────────────────────────────
Sposta la pagina e fa lampeggiare la zona, poi porta il focus da
tastiera sul primo comando: chi naviga senza mouse deve trovarsi
dentro la zona, non doverci arrivare a furia di tabulazioni. --}}
{{-- x-data vuoto ma necessario: x-on non funziona senza uno scope Alpine
sopra di sé, e queste voci non ne avrebbero altrimenti nessuno. --}}
<nav class="tm-nav-zone" aria-label="Zone della pagina" x-data="{}">
<span class="text-xs text-gray-500 self-center mr-1">Vai a</span>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-backup', true)">1 &middot; Backup</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-ripristino', true)">2 &middot; Ripristino</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-pdf', true)">3 &middot; Import PDF</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-xml', true)">4 &middot; XML</button>
<button type="button" class="tm-nav-zona tm-nav-zona-pericolo" x-on:click="window.tmFocusZona('zona-azzeramento', true)">5 &middot; Zona pericolosa</button>
</nav>
{{-- ═══ Zone 1 e 2 ════════════════════════════════════════════════════ --}}
<livewire:settings.backup-manager />
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
x-data="{
uploadLog: '',
uploadProgress: 0,
uploading: false,
zipUploading: false,
zipProgress: 0,
selectedFiles: 0,
selectedZip: '',
append(message) {
this.uploadLog = this.uploadLog ? this.uploadLog + '\n' + message : message;
},
submitZip(event) {
const form = event.target;
const formData = new FormData(form);
{{-- ═══ Zona 3 — Import PDF territori ═════════════════════════════════ --}}
<section id="zona-pdf"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5 {{ $this->importPdfInCorso ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-pdf"
x-data="{
fileScelti: 0,
zipScelto: '',
zipInCorso: false,
zipPercento: 0,
this.uploadLog = '';
this.zipUploading = true;
this.zipProgress = 0;
this.append('Upload ZIP diretto al server avviato...');
log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'import-pdf' });
},
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (uploadEvent) => {
if (!uploadEvent.lengthComputable) {
return;
cartellaScelta(event) {
this.fileScelti = event.target.files.length;
if (this.fileScelti > 0) {
this.log(`Cartella scelta: ${this.fileScelti} file.`);
this.log(`In attesa del caricamento temporaneo di Livewire...`, 'attesa');
}
},
this.zipProgress = Math.round((uploadEvent.loaded / uploadEvent.total) * 100);
});
xhr.addEventListener('load', () => {
this.zipUploading = false;
let payload = {};
try {
payload = JSON.parse(xhr.responseText || '{}');
} catch (error) {
payload = {};
zipSceltoDa(event) {
const file = event.target.files[0];
this.zipScelto = file ? file.name : '';
this.zipPercento = 0;
if (file) {
this.log(`Archivio ZIP scelto: ${file.name} — ${(file.size / 1000000).toFixed(1)} MB`);
}
},
if (xhr.status >= 200 && xhr.status < 300) {
this.zipProgress = 100;
this.append('Archivio ricevuto. Reindirizzamento alla console import...');
window.location = payload.redirect_url || window.location.href;
return;
}
inviaZip(event) {
const form = event.target;
const message = payload.message || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : 'Errore durante il caricamento dello ZIP.');
this.append(message);
});
this.zipInCorso = true;
this.zipPercento = 0;
this.$dispatch('console-apri');
this.log(`Invio dell'archivio ZIP al server...`);
xhr.addEventListener('error', () => {
this.zipUploading = false;
this.append('Errore di rete durante il caricamento dello ZIP.');
});
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(formData);
}
}"
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { uploading = true; uploadProgress = 0; append('Upload cartella avviato...'); }"
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { uploadProgress = $event.detail.progress; append('Upload Livewire in corso: ' + $event.detail.progress + '%'); }"
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { uploading = false; uploadProgress = 100; append('Upload completato. Avvio preparazione import lato server...'); }"
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { uploading = false; append('Errore durante l\'upload temporaneo dei file.'); }">
<h2 class="text-lg font-semibold text-gray-900">Import PDF territori</h2>
<p class="text-xs text-gray-500">Puoi importare una cartella di PDF oppure, meglio per archivi grandi, un file ZIP contenente i PDF. Il nome file puo variare: basta che contenga il numero di un territorio gia presente nell'app. I PDF verranno associati ai territori esistenti e verra generata anche la thumbnail.</p>
let ultimaTacca = -1;
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) {
return;
}
this.zipPercento = Math.round((e.loaded / e.total) * 100);
const tacca = Math.floor(this.zipPercento / 10);
if (tacca > ultimaTacca) {
ultimaTacca = tacca;
this.log(`Caricamento ZIP ${this.zipPercento}%`);
}
});
<form wire:submit.prevent="importTerritoryPdfFolder" class="space-y-4">
<div>
<input wire:model="pdfFolder"
x-on:change="selectedFiles = $event.target.files.length; uploadLog = ''; if (selectedFiles > 0) { append('Cartella selezionata: ' + selectedFiles + ' file.'); append('In attesa dell\'upload temporaneo Livewire...'); }"
type="file"
multiple
webkitdirectory
directory
accept=".pdf,application/pdf"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('pdfFolder') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@error('pdfFolder.*') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
xhr.addEventListener('load', () => {
this.zipInCorso = false;
<div x-show="selectedFiles > 0 || uploading" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploading ? 'Upload file in corso...' : 'Upload file pronto'"></span>
<span x-text="selectedFiles > 0 ? selectedFiles + ' file selezionati' : ''"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width:' + uploadProgress + '%' "></div>
</div>
</div>
let payload = {};
try {
payload = JSON.parse(xhr.responseText || '{}');
} catch (errore) {
payload = {};
}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
wire:loading.attr="disabled"
@disabled(empty($pdfFolder))
>
Importa PDF territori
</button>
<div wire:loading wire:target="importTerritoryPdfFolder" class="text-sm text-indigo-700">Preparazione import in corso...</div>
</div>
</form>
if (xhr.status >= 200 && xhr.status < 300) {
this.zipPercento = 100;
this.log(`Archivio ricevuto. La pagina si ricarica sulla console dell'import.`, 'ok');
window.location = payload.redirect_url || window.location.href;
return;
}
<div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-semibold text-gray-900">Import da archivio ZIP</h3>
<p class="mt-1 text-xs text-gray-500">Consigliato per grandi volumi: carichi un solo file e il server estrae automaticamente tutti i PDF.</p>
this.zipPercento = 0;
const messaggio = payload.message
|| (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : `Errore durante il caricamento dello ZIP (HTTP ${xhr.status}).`);
this.log(`ERRORE: ${messaggio}`, 'errore');
});
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-4" @submit.prevent="submitZip">
xhr.addEventListener('error', () => {
this.zipInCorso = false;
this.zipPercento = 0;
this.log(`ERRORE DI RETE durante il caricamento dello ZIP.`, 'errore');
});
xhr.send(new FormData(form));
},
}"
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { $dispatch('console-apri'); log('Caricamento della cartella avviato...'); }"
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { log('Caricamento cartella: ' + $event.detail.progress + '%'); }"
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { log('Cartella caricata. Preparazione import sul server...', 'ok'); }"
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { log('ERRORE durante il caricamento temporaneo dei file.', 'errore'); }">
<div>
<h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 &middot; Import PDF territori</h2>
<p class="text-xs text-gray-500 mt-1">
Associa i PDF ai territori gi&agrave; presenti e ne genera le miniature. Il nome del file pu&ograve;
variare: basta che contenga il numero di un territorio esistente.
</p>
</div>
{{-- Due strade per la stessa cosa. La primaria e' una sola — l'archivio
ZIP — perche' e' quella che regge i volumi veri: un solo file da
caricare invece di centinaia di richieste temporanee. --}}
<div class="grid gap-4 md:grid-cols-2">
{{-- Strada consigliata --}}
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data"
class="rounded-lg border border-gray-200 p-4 space-y-3" @submit.prevent="inviaZip($event)">
@csrf
<div>
<input name="pdfZip"
x-on:change="selectedZip = $event.target.files[0] ? $event.target.files[0].name : ''; uploadLog = ''; zipProgress = 0; if (selectedZip) { append('Archivio ZIP selezionato: ' + selectedZip); append('Pronto per upload diretto al server.'); }"
type="file"
accept=".zip,application/zip"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100">
@error('pdfZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<h3 class="text-sm font-semibold text-gray-900">Da archivio ZIP <span class="text-xs font-normal text-emerald-700">&mdash; consigliato</span></h3>
<p class="mt-1 text-xs text-gray-500">Un solo file: il server estrae i PDF da s&eacute;. &Egrave; la via da usare per grandi volumi.</p>
</div>
<div x-show="selectedZip || zipUploading" x-cloak class="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-emerald-800">
<span x-text="zipUploading ? 'Upload ZIP in corso...' : 'ZIP pronto per l\'invio' "></span>
<span x-text="selectedZip"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-emerald-100">
<div class="h-full rounded-full bg-emerald-600 transition-all duration-300" :style="'width:' + zipProgress + '%' "></div>
</div>
<div>
<label for="pdfZip" class="sr-only">Archivio ZIP dei PDF</label>
<input id="pdfZip" name="pdfZip" type="file" accept=".zip,application/zip"
data-zona-focus
x-on:change="zipSceltoDa($event)"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('pdfZip') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
x-bind:disabled="!selectedZip || zipUploading"
>
Importa ZIP PDF
</button>
<div x-show="zipUploading" x-cloak class="text-sm text-emerald-700">Caricamento ZIP diretto in corso...</div>
<div x-show="zipInCorso || zipPercento > 0" x-cloak class="h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-200" x-bind:style="'width:' + zipPercento + '%'"></div>
</div>
<x-btn variante="primary" type="submit" x-bind:disabled="!zipScelto || zipInCorso">
Importa archivio ZIP
</x-btn>
</form>
{{-- Strada alternativa --}}
<form wire:submit.prevent="importTerritoryPdfFolder" class="rounded-lg border border-gray-200 p-4 space-y-3">
<div>
<h3 class="text-sm font-semibold text-gray-900">Da cartella</h3>
<p class="mt-1 text-xs text-gray-500">Comoda per pochi file. Ogni PDF viaggia come caricamento temporaneo a s&eacute;.</p>
</div>
<div>
<label for="pdfFolder" class="sr-only">Cartella di PDF</label>
<input wire:model="pdfFolder" id="pdfFolder" type="file" multiple webkitdirectory directory
accept=".pdf,application/pdf"
x-on:change="cartellaScelta($event)"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
@error('pdfFolder') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
@error('pdfFolder.*') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak x-text="fileScelti + ' file selezionati'"></p>
<x-btn variante="secondary" type="submit"
x-on:click="$dispatch('console-apri')"
wire:loading.attr="disabled"
{{-- Vedi la nota in components/btn.blade.php: dentro un
tag di componente serve :disabled, non @disabled. --}}
:disabled="empty($pdfFolder)">
Importa cartella
</x-btn>
<span wire:loading wire:target="importTerritoryPdfFolder" class="text-xs text-indigo-700">Preparazione import...</span>
</form>
</div>
<div x-show="uploadLog || uploading || zipUploading || selectedZip" x-cloak>
<div class="mb-2 text-sm font-medium text-gray-800">Console upload</div>
<textarea readonly rows="8" x-bind:value="uploadLog" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500"></textarea>
</div>
{{-- Esito. I log stanno nella console; qui restano i numeri e l'elenco
dei file che non sono stati associati, che e' l'unica cosa su cui si
deve poi agire a mano. --}}
@if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportIssues)))
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-4"
@if($this->importPdfInCorso) wire:poll.1000ms="refreshPdfImportStatus" @endif>
@if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportLogs)))
<div class="rounded-xl border border-indigo-200 bg-indigo-50/40 p-4" @if(in_array($pdfImportStatus, ['queued', 'running'], true)) wire:poll.1000ms="refreshPdfImportStatus" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-gray-800">Stato import PDF</div>
<div class="mt-1 text-xs text-gray-500">ID import: {{ $currentPdfImportId }}</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-medium text-gray-800">Esito dell&rsquo;import</div>
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold
{{ match($pdfImportStatus) {
'queued' => 'bg-amber-100 text-amber-800',
'running' => 'bg-blue-100 text-blue-800',
'completed' => 'bg-green-100 text-green-800',
'failed' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-700',
} }}">
{{ match($pdfImportStatus) {
'queued' => 'In coda',
'running' => 'In esecuzione',
'completed' => 'Completato',
'failed' => 'Fallito',
default => 'Inattivo',
} }}
</span>
</div>
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold
{{ match($pdfImportStatus) {
'queued' => 'bg-amber-100 text-amber-800',
'running' => 'bg-blue-100 text-blue-800',
'completed' => 'bg-green-100 text-green-800',
'failed' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-700',
} }}">
{{ match($pdfImportStatus) {
'queued' => 'In coda',
'running' => 'In esecuzione',
'completed' => 'Completato',
'failed' => 'Fallito',
default => 'Inattivo',
} }}
</span>
</div>
@if(!empty($pdfImportStats))
<div class="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="rounded-lg bg-white px-3 py-2 border border-gray-200">
<div class="text-xs text-gray-500">Processati</div>
<div class="font-semibold text-gray-900">{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-green-50 px-3 py-2 border border-green-100">
<div class="text-xs text-green-700">Aggiornati</div>
<div class="font-semibold text-green-900">{{ $pdfImportStats['updated'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-amber-50 px-3 py-2 border border-amber-100">
<div class="text-xs text-amber-700">Saltati</div>
<div class="font-semibold text-amber-900">{{ $pdfImportStats['skipped'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-red-50 px-3 py-2 border border-red-100">
<div class="text-xs text-red-700">Errori</div>
<div class="font-semibold text-red-900">{{ $pdfImportStats['errors'] ?? 0 }}</div>
</div>
</div>
@endif
@if(!empty($pdfImportStats))
<div class="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="rounded-lg bg-white px-3 py-2 border border-gray-200">
<div class="text-xs text-gray-500">Processati</div>
<div class="font-semibold text-gray-900">{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-green-50 px-3 py-2 border border-green-100">
<div class="text-xs text-green-700">Aggiornati</div>
<div class="font-semibold text-green-900">{{ $pdfImportStats['updated'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-amber-50 px-3 py-2 border border-amber-100">
<div class="text-xs text-amber-700">Saltati</div>
<div class="font-semibold text-amber-900">{{ $pdfImportStats['skipped'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-red-50 px-3 py-2 border border-red-100">
<div class="text-xs text-red-700">Errori</div>
<div class="font-semibold text-red-900">{{ $pdfImportStats['errors'] ?? 0 }}</div>
</div>
</div>
@endif
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-800">Log import PDF</div>
<button type="button" wire:click="refreshPdfImportStatus" class="inline-flex items-center justify-center rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-white transition">
Aggiorna log
</button>
</div>
<textarea readonly rows="12" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 sm:rows-14">{{ $pdfImportLogText }}</textarea>
@if(!empty($pdfImportIssues))
<div>
<div class="mb-2 text-sm font-medium text-gray-800">File non associati o problematici</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">File</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Territori rilevati</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($pdfImportIssues as $issue)
<tr>
<td data-label="File" class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
<td data-label="Territori rilevati" class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
@if(!empty($pdfImportIssues))
<div class="mt-4">
<div class="mb-2 text-sm font-medium text-gray-800">Riepilogo file non associati o problematici</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">File</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Territori rilevati</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($pdfImportIssues as $issue)
<tr>
<td data-label="File" class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
<td data-label="Territori rilevati" class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
@endif
</div>
</section>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Conversione dump SQL legacy</h2>
<p class="text-xs text-gray-500">Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.</p>
{{-- ═══ Zona 4 — Dati in formato XML ══════════════════════════════════ --}}
<section id="zona-xml"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5"
aria-labelledby="titolo-xml">
<div>
<h2 id="titolo-xml" class="text-lg font-semibold text-gray-900">4 &middot; Dati in formato XML</h2>
<p class="text-xs text-gray-500 mt-1">
Scambio dei soli dati gestionali, senza i file caricati. Per un backup davvero completo
usa il pacchetto .zip della <button type="button" class="underline" x-on:click="window.tmFocusZona('zona-backup', true)">zona 1</button>:
ripristinando solo l&rsquo;XML i territori restano senza PDF n&eacute; miniature.
</p>
</div>
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
@csrf
<div>
<input name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="flex gap-2 mt-4">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
>
Converti in XML
</button>
</div>
</form>
</div>
{{-- Export --}}
<div class="rounded-lg border border-gray-200 p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Esporta</h3>
<p class="text-xs text-gray-500">Tutti i dati correnti in un file XML, reimportabile qui accanto.</p>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Import XML nell'app</h2>
<p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
@csrf
<div>
<input name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2 mt-4">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
>
Importa XML
</button>
</div>
</form>
</div>
@if(!empty($importStats))
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-3">
<h2 class="text-lg font-semibold text-gray-900">Log importazione</h2>
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
<div>Utenti importati: <strong>{{ $importStats['utenti_importati'] ?? 0 }}</strong></div>
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
<div>Territori importati: <strong>{{ $importStats['territori_importati'] ?? 0 }}</strong></div>
<div>Anni importati: <strong>{{ $importStats['anni_importati'] ?? 0 }}</strong></div>
<div>Campagne importate: <strong>{{ $importStats['campagne_importate'] ?? 0 }}</strong></div>
<div>Assegnazioni importate: <strong>{{ $importStats['assegnazioni_importate'] ?? 0 }}</strong></div>
<div>Territori duplicati saltati: <strong>{{ $importStats['duplicate_territori'] ?? 0 }}</strong></div>
<div>Assegnazioni saltate: <strong>{{ $importStats['assegnazioni_saltate'] ?? 0 }}</strong></div>
</div>
<div class="mt-3">
<button
wire:click="downloadImportLogPdf"
type="button"
style="display:inline-flex;align-items:center;gap:6px;background:#1d4ed8;color:#fff;border:1px solid #1e3a8a;border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer;"
>
<svg style="width:15px;height:15px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Scarica log PDF
</button>
</div>
@if(!empty($importIssues))
<div class="mt-2" style="max-height:260px;overflow:auto;border:1px solid #e5e7eb;border-radius:10px;">
<table class="table-cards" style="width:100%;border-collapse:collapse;font-size:13px;">
<thead style="background:#f9fafb;position:sticky;top:0;">
<tr>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Entità</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Legacy ID</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Motivo</th>
</tr>
</thead>
<tbody>
@foreach($importIssues as $issue)
<tr>
<td data-label="Entità" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['entity'] }}</td>
<td data-label="Legacy ID" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['legacy_id'] }}</td>
<td data-label="Motivo" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['reason'] }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei
proclamatori in chiaro: chi lo ottiene pu&ograve; tentare di forzare le password offline.
</div>
@endif
</div>
@endif
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Export XML (solo dati)</h2>
<p class="text-xs text-gray-500">
Esporta <strong>tutti</strong> i dati correnti dell'app in XML, reimportabile qui sopra.
<strong>Non</strong> include i file caricati: ripristinando solo questo, i territori restano
senza PDF né miniature. Per un backup davvero completo usa il pacchetto .zip in cima alla pagina.
</p>
<x-btn variante="secondary" wire:click="exportCurrentAsXml" data-zona-focus>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Esporta XML
</x-btn>
</div>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei proclamatori in chiaro.
Conservalo in un luogo sicuro e non condividerlo: chi lo ottiene può tentare di forzare le password offline.
{{-- Import --}}
<div class="rounded-lg border border-red-200 p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Importa</h3>
<p class="text-xs text-gray-500">
<strong>Sostituisce</strong> zone, tipologie, proclamatori, territori, anni, campagne,
assegnazioni e impostazioni.
</p>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" class="space-y-3"
onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
@csrf
<div>
<label for="xmlImport" class="sr-only">File XML da importare</label>
<input id="xmlImport" name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
@error('xmlImport') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<x-btn variante="danger" type="submit">Importa XML</x-btn>
</form>
</div>
</div>
<div class="flex gap-2">
<button
wire:click="exportCurrentAsXml"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
>
Esporta XML
</button>
</div>
</div>
{{-- Conversione legacy: e' una tantum, quindi sta in fondo alla zona e
non si prende una colonna accanto alle due operazioni ricorrenti. --}}
<details class="rounded-lg border border-gray-200 p-4">
<summary class="cursor-pointer text-sm font-semibold text-gray-900">Converti un dump SQL della vecchia applicazione</summary>
<p class="mt-2 text-xs text-gray-500">
Carica il file SQL (per esempio <code class="rounded bg-gray-100 px-1">dump-termanager-202604071526.sql</code>)
e ottieni un XML compatibile con l&rsquo;import qui sopra. Non scrive niente nell&rsquo;applicazione:
produce solo un file da scaricare.
</p>
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-3">
@csrf
<div>
<label for="sqlDump" class="sr-only">Dump SQL da convertire</label>
<input id="sqlDump" name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
@error('sqlDump') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<x-btn variante="secondary" type="submit">Converti in XML</x-btn>
</form>
</details>
{{-- Esito dell'import XML. L'import e' sincrono (una POST normale), quindi
non ha un flusso di log: ha un riepilogo, e sta qui accanto al
pulsante che lo ha prodotto. --}}
@if(!empty($importStats))
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-3">
<div class="text-sm font-medium text-gray-800">Esito dell&rsquo;ultimo import XML</div>
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
<div>Utenti importati: <strong>{{ $importStats['utenti_importati'] ?? 0 }}</strong></div>
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
<div>Territori importati: <strong>{{ $importStats['territori_importati'] ?? 0 }}</strong></div>
<div>Anni importati: <strong>{{ $importStats['anni_importati'] ?? 0 }}</strong></div>
<div>Campagne importate: <strong>{{ $importStats['campagne_importate'] ?? 0 }}</strong></div>
<div>Assegnazioni importate: <strong>{{ $importStats['assegnazioni_importate'] ?? 0 }}</strong></div>
<div>Territori duplicati saltati: <strong>{{ $importStats['duplicate_territori'] ?? 0 }}</strong></div>
<div>Assegnazioni saltate: <strong>{{ $importStats['assegnazioni_saltate'] ?? 0 }}</strong></div>
</div>
<x-btn variante="secondary" dimensione="sm" wire:click="downloadImportLogPdf">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Scarica il riepilogo in PDF
</x-btn>
@if(!empty($importIssues))
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white" style="max-height:260px;">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Entit&agrave;</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Legacy ID</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($importIssues as $issue)
<tr>
<td data-label="Entità" class="px-3 py-2 text-gray-900">{{ $issue['entity'] }}</td>
<td data-label="Legacy ID" class="px-3 py-2 text-gray-600">{{ $issue['legacy_id'] }}</td>
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['reason'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
</section>
{{-- ═══ Zona 5 ════════════════════════════════════════════════════════ --}}
<livewire:settings.zona-pericolosa />
{{-- Console unica, agganciata in fondo alla finestra. --}}
@include('livewire.settings.partials.console-operazioni')
</div>

View File

@@ -0,0 +1,174 @@
{{--
Ultima zona della pagina: l'azzeramento.
Il secondo tempo (verifica del backup, password, conferma) compare QUI, dove
e' stato premuto il primo. Prima usciva in un pannello di avanzamento in cima
alla pagina, a schermate di distanza dal pulsante che lo aveva avviato: chi
lo cercava non lo trovava, e la conferma di un'operazione irreversibile e'
l'ultimo posto in cui ci si puo' permettere di far cercare qualcosa.
Regole del blocco x-data qui sotto, come nel resto della pagina: nessuna
virgoletta doppia, da nessuna parte, neanche nei commenti — un attributo HTML
finisce alla prima. Gli apostrofi stanno nei backtick.
--}}
@php
$stato = $operazione['status'] ?? null;
$attesa = $this->attesaDownload;
$mioAzzeramento = $this->azzeramentoInCorso;
// Il posto puo' essere occupato da un backup o da un ripristino avviati
// nelle zone di sopra: il pulsante si spegne, ma per un motivo diverso.
$occupatoDaAltri = $this->inCorso && ! $mioAzzeramento;
@endphp
<div x-data="{
password: '',
azzera() {
if (!this.password) {
return;
}
if (!confirm('Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente.')) {
return;
}
const password = this.password;
// Svuotata subito: se e' sbagliata si ridigita, e intanto non resta
// nel campo di una scheda lasciata aperta.
this.password = '';
this.$dispatch('console-apri');
this.$wire.confermaAzzeramento(password);
},
}"
{{-- Vedi la nota gemella in backup-manager: il poll si spegne da solo
quando l'operazione chiude. --}}
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
<section id="zona-azzeramento"
class="tm-zona rounded-xl border-2 border-red-300 bg-red-50/40 p-6 space-y-4 {{ $mioAzzeramento ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-azzeramento">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 flex-none text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
<div>
<h2 id="titolo-azzeramento" class="text-lg font-semibold text-red-900">5 &middot; Zona pericolosa</h2>
<p class="text-xs text-red-800 mt-1">Operazioni irreversibili. Leggi prima di premere.</p>
</div>
</div>
@error('confermaReset')
<p class="rounded-lg border border-red-300 bg-white px-3 py-2 text-xs font-medium text-red-700" role="alert">{{ $message }}</p>
@enderror
{{-- ─── Secondo tempo: l'archivio c'e', manca la conferma ────────── --}}
@if($attesa)
<div class="rounded-lg border-2 border-amber-400 bg-amber-50 p-4 space-y-3"
x-init="window.tmFocusZona('zona-azzeramento', false)">
<p class="text-sm text-amber-900">
<strong>L&rsquo;archivio &egrave; pronto e il download &egrave; partito.</strong>
Non &egrave; stato cancellato ancora nulla: l&rsquo;azzeramento aspetta la tua conferma.
</p>
<p class="text-xs text-amber-800">
Apri il file scaricato e verifica che sia integro. Il server sa di aver <em>spedito</em>
l&rsquo;archivio, non pu&ograve; sapere se &egrave; arrivato intero: quella verifica tocca a te,
ed &egrave; l&rsquo;ultima possibilit&agrave; di tornare indietro.
</p>
{{-- La password sta in Alpine, non in una proprietà del
componente: le proprietà pubbliche di Livewire tornano
al browser dentro lo snapshot a ogni risposta, e una
password non ha motivo di fare quel viaggio. Passata
come parametro del metodo, viaggia solo in andata. --}}
<div class="max-w-xs">
<label for="passwordConferma" class="block text-xs font-medium text-amber-900">
Conferma con la tua password
</label>
<input type="password" id="passwordConferma" autocomplete="current-password"
data-zona-focus
x-model="password"
x-on:keydown.enter.prevent="azzera()"
class="mt-1 block w-full rounded-lg border-amber-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm">
@error('passwordConferma') <p class="mt-1 text-xs font-medium text-red-700" role="alert">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="secondary" :href="route('backup.file', $operationId)">
Scarica di nuovo
</x-btn>
<x-btn variante="danger"
x-on:click="azzera()"
x-bind:disabled="!password">
Ho salvato il backup, azzera tutto
</x-btn>
<x-btn variante="ghost" wire:click="annullaAzzeramento">
Annulla l&rsquo;azzeramento
</x-btn>
</div>
</div>
@endif
{{-- ─── Primo tempo ─────────────────────────────────────────────── --}}
<div class="rounded-lg border border-red-300 bg-white p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Azzera tutto e riparti da zero</h3>
<p class="text-xs text-gray-600">
Riporta l&rsquo;applicazione allo stato di installazione appena eseguita. In ordine:
</p>
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ci&ograve; che c&rsquo;&egrave; adesso.</li>
<li>L&rsquo;operazione si ferma e aspetta: <strong>controlli il file scaricato, digiti la tua password e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attivit&agrave; e <strong>tutti i file caricati</strong>.</li>
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale &mdash; come un&rsquo;installazione nuova, che ha un amministratore solo.</li>
</ol>
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
Il backup scaricato &egrave; l&rsquo;<strong>unico</strong> modo per tornare indietro. Verifica di averlo
salvato prima di chiudere il browser.
</div>
<div>
<label for="confermaReset" class="block text-xs font-medium text-gray-700">
Per confermare, scrivi <code class="rounded bg-gray-100 px-1 py-0.5 font-mono">{{ \App\Livewire\Settings\ZonaPericolosa::CONFERMA_AZZERAMENTO }}</code>
</label>
<input wire:model="confermaReset" type="text" id="confermaReset" autocomplete="off"
data-zona-focus
@disabled($attesa)
class="mt-1 block w-full max-w-xs rounded-lg border-gray-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm font-mono">
</div>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="danger"
wire:click="avviaAzzeramento"
wire:confirm="Ultima conferma. Verrà scaricato un backup e poi l'applicazione sarà azzerata. L'operazione non è reversibile."
x-on:click="$dispatch('console-apri')"
{{-- Vedi la nota in components/btn.blade.php: dentro un
tag di componente serve :disabled, non @disabled. --}}
:disabled="$this->inCorso">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.87 12.14A2 2 0 0116.14 21H7.86a2 2 0 01-1.99-1.86L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3M4 7h16"/></svg>
Scarica backup e azzera tutto
</x-btn>
@if($occupatoDaAltri)
<span class="text-xs text-gray-600">
Un backup o un ripristino sono in corso: l&rsquo;azzeramento pu&ograve; partire solo a operazione finita.
</span>
@elseif($mioAzzeramento && ! $attesa)
<span class="text-xs text-gray-600">
Azzeramento in corso: l&rsquo;avanzamento &egrave; nella console in fondo alla finestra.
</span>
@endif
</div>
@if($stato === 'failed' && $mioAzzeramento)
<p class="text-sm text-red-800" role="alert">{{ $operazione['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && ($operazione['result']['messaggio'] ?? null) && ($operazione['tipo'] ?? null) === 'reset')
<p class="text-sm text-green-900">{{ $operazione['result']['messaggio'] }}</p>
@endif
</div>
</section>
</div>

View File

@@ -11,7 +11,7 @@
<input wire:model="nuovaZona" type="text" id="nuovaZona" placeholder="Nome zona..." class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('nuovaZona') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Aggiungi</button>
<x-btn variante="primary" type="submit">Aggiungi</x-btn>
</form>
</div>
@@ -23,8 +23,8 @@
@if($editingId === $zona->id)
<form wire:submit="saveEdit" class="flex items-center gap-2 flex-1">
<input wire:model="editingNome" type="text" class="flex-1 rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<button type="submit" class="text-green-600 hover:text-green-800 text-xs font-medium">Salva</button>
<button type="button" wire:click="cancelEdit" class="text-gray-500 hover:text-gray-700 text-xs">Annulla</button>
<x-btn variante="primary" dimensione="sm" type="submit">Salva</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="cancelEdit">Annulla</x-btn>
</form>
@else
<div class="flex items-center gap-2">
@@ -35,9 +35,9 @@
<span class="text-xs text-gray-400">{{ $zona->territori()->count() }} territori</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button wire:click="startEdit({{ $zona->id }})" class="text-yellow-600 hover:text-yellow-800 text-xs">Rinomina</button>
<button wire:click="toggleActive({{ $zona->id }})" class="text-xs {{ $zona->attivo ? 'text-gray-500' : 'text-green-600' }}">{{ $zona->attivo ? 'Disattiva' : 'Attiva' }}</button>
<button wire:click="deleteZona({{ $zona->id }})" wire:confirm="Eliminare la zona '{{ $zona->nome }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $zona->id }})">Rinomina</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="toggleActive({{ $zona->id }})">{{ $zona->attivo ? 'Disattiva' : 'Attiva' }}</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="deleteZona({{ $zona->id }})" wire:confirm="Eliminare la zona '{{ $zona->nome }}'?">Elimina</x-btn>
</div>
@endif
</li>

View File

@@ -24,8 +24,8 @@
<td data-label="Eliminato il" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->deleted_at->format('d/m/Y H:i') }}</td>
<td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1">
<button wire:click="restore({{ $territorio->id }})" class="btn-action btn-action-green">Ripristina</button>
<button wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
<x-btn variante="primary" tono="tenue" dimensione="sm" wire:click="restore({{ $territorio->id }})">Ripristina</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile.">Elimina</x-btn>
</div>
</td>
</tr>

View File

@@ -56,16 +56,16 @@
@if($isEdit && $this->territorio->pdf_path)
<div class="mt-2 flex items-center gap-2 text-sm">
<span class="text-green-600">PDF presente</span>
<button type="button" wire:click="removePdf" wire:confirm="Rimuovere il PDF?" class="text-red-600 hover:text-red-800 text-xs underline">Rimuovi PDF</button>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="removePdf" wire:confirm="Rimuovere il PDF?">Rimuovi PDF</x-btn>
</div>
@endif
</div>
<div class="flex gap-3 pt-2">
<a href="{{ route('territori.index') }}" class="px-4 py-2.5 rounded-lg text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 transition">Annulla</a>
<button type="submit" class="px-6 py-2.5 rounded-lg text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 transition">
{{ $isEdit ? 'Aggiorna' : 'Crea' }}
</button>
{{-- Azione principale prima, uscita dopo: stesso ordine in tutti i form
dell'applicazione. Prima cambiava da una pagina all'altra. --}}
<div class="flex flex-wrap gap-3 pt-2">
<x-btn variante="primary" type="submit">{{ $isEdit ? 'Aggiorna' : 'Crea' }}</x-btn>
<x-btn variante="secondary" :href="route('territori.index')">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -6,14 +6,14 @@
</div>
@can('territori.manage')
<div class="mt-4 sm:mt-0 flex gap-2">
<a href="{{ route('territori.cestino') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
<svg class="h-4 w-4" style="color:#9ca3af" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Cestino
</a>
<a href="{{ route('territori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" :href="route('territori.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo territorio
</a>
</x-btn>
<x-btn variante="secondary" :href="route('territori.cestino')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Cestino
</x-btn>
</div>
@endcan
</div>
@@ -143,38 +143,54 @@
{{-- Primary actions: Assegna / Rientra --}}
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna
</a>
</x-btn>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
{{-- Rientra non cancella niente: era rosso pieno
quanto un Elimina. È l'azione principale
della riga, alternativa ad Assegna. --}}
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra
</a>
</x-btn>
@endif
@endcan
{{-- Secondary actions --}}
<a href="{{ route('territori.show', $territorio) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
{{-- Solo icona: l'aria-label non è ridondante col
title, è l'unico nome che un lettore di
schermo annuncia in modo affidabile. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('territori.show', $territorio)"
title="Dettaglio" aria-label="Dettaglio del territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn>
@can('territori.manage')
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</a>
<button wire:click="toggleActive({{ $territorio->id }})" class="btn-action btn-action-amber" title="{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}">
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('territori.edit', $territorio)"
title="Modifica" aria-label="Modifica il territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="toggleActive({{ $territorio->id }})"
title="{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}"
aria-label="{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }} il territorio {{ $territorio->numero }}">
@if($territorio->attivo)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif
</button>
<button wire:click="deleteTerritorio({{ $territorio->id }})" wire:confirm="Spostare il territorio {{ $territorio->numero }} nel cestino?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteTerritorio({{ $territorio->id }})"
wire:confirm="Spostare il territorio {{ $territorio->numero }} nel cestino?"
title="Elimina" aria-label="Sposta nel cestino il territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
@endcan
</div>
</td>

View File

@@ -10,25 +10,28 @@
<div class="flex items-center gap-2 flex-wrap">
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna
</a>
</x-btn>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
{{-- Rientra era rosso pieno quanto un Elimina. Non cancella
niente: è l'azione principale di un territorio assegnato,
quindi primary come Assegna, che è la sua alternativa. --}}
<x-btn variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra
</a>
</x-btn>
@endif
@endcan
@can('territori.manage')
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
<x-btn variante="secondary" :href="route('territori.edit', $territorio)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Modifica
</a>
</x-btn>
@endcan
</div>
</div>
@@ -96,7 +99,7 @@
</div>
<div class="flex flex-wrap gap-2">
@can('territori.return')
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id]) }}" class="inline-flex items-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition">Rientra</a>
<x-btn variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id])">Rientra</x-btn>
@endcan
</div>
</div>
@@ -108,11 +111,11 @@
<div class="text-sm font-medium text-indigo-900">Link PDF temporaneo</div>
<div class="mt-1 text-xs text-indigo-700">Valido fino al {{ optional($assignmentScadenza)->format('d/m/Y') ?? '—' }} (soglia rientro) o fino al rientro del territorio.</div>
</div>
<a href="{{ $temporaryPdfUrl }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 transition">Apri viewer</a>
<x-btn variante="secondary" :href="$temporaryPdfUrl" target="_blank" rel="noopener noreferrer">Apri viewer</x-btn>
</div>
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
<input x-ref="assignmentPdfLink" type="text" readonly value="{{ $temporaryPdfUrl }}" class="block w-full rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-indigo-500 focus:ring-indigo-500">
<button type="button" @click="navigator.clipboard.writeText($refs.assignmentPdfLink.value); copied = true; setTimeout(() => copied = false, 1800);" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">Copia link</button>
<x-btn variante="primary" @click="navigator.clipboard.writeText($refs.assignmentPdfLink.value); copied = true; setTimeout(() => copied = false, 1800);">Copia link</x-btn>
</div>
<p x-show="copied" x-cloak class="mt-2 text-xs font-medium text-green-700">Link copiato.</p>
</div>