++ 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

@@ -2,6 +2,12 @@
/* ─── TerManager2 Custom UI ─── */
/* Alpine nasconde con x-cloak solo se il foglio di stile lo dice: la regola non
arriva col pacchetto. Senza, gli elementi marcati x-cloak (le barre di
avanzamento, i messaggi d'errore) lampeggiano a ogni caricamento prima che
Alpine parta. */
[x-cloak] { display: none !important; }
/* Card hover lift effect */
.card-hover {
transition: transform 0.15s ease, box-shadow 0.15s ease;
@@ -23,39 +29,6 @@
background-color: #f9fafb;
}
/* Action buttons */
.btn-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
transition: all 0.15s ease;
border: none;
cursor: pointer;
text-decoration: none;
}
.btn-action-green { background: #dcfce7; color: #166534; }
.btn-action-green:hover { background: #bbf7d0; }
.btn-action-red { background: #fee2e2; color: #991b1b; }
.btn-action-red:hover { background: #fecaca; }
.btn-action-blue { background: #dbeafe; color: #1e40af; }
.btn-action-blue:hover { background: #bfdbfe; }
.btn-action-amber { background: #fef3c7; color: #92400e; }
.btn-action-amber:hover { background: #fde68a; }
.btn-action-gray { background: #f3f4f6; color: #374151; }
.btn-action-gray:hover { background: #e5e7eb; }
.btn-action-indigo { background: #e0e7ff; color: #4338ca; }
.btn-action-indigo:hover { background: #c7d2fe; }
/* Primary action buttons (filled) */
.btn-primary-green { background: #16a34a; color: #fff; }
.btn-primary-green:hover { background: #15803d; }
.btn-primary-red { background: #dc2626; color: #fff; }
.btn-primary-red:hover { background: #b91c1c; }
/* Gradient header */
.header-gradient {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 50%, #818cf8 100%);
@@ -102,6 +75,304 @@
50% { opacity: 0.7; }
}
/* ─── Pulsanti ─────────────────────────────────────────────────────────────
Un solo sistema per tutta l'applicazione, definito qui e in nessun altro
posto. Si usa dal componente <x-btn>, mai scrivendo queste classi a mano.
Da dove veniamo: quattro vocabolari in circolazione contemporaneamente —
style= inline con i colori scritti a mano (piu' onmouseover per l'hover),
catene di utility Tailwind ripetute a memoria, .btn-action* in sei tinte, e
.btn-primary-* in due. La stessa azione cambiava aspetto da una pagina
all'altra, un "Rientra" era rosso quanto un "Elimina", e nessuno di questi
pulsanti aveva uno stato di focus: chi naviga da tastiera non vedeva dove si
trovava.
Due assi, ortogonali:
variante primary l'azione principale. Una sola per schermata o 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. Per le azioni isolate.
tenue la tinta chiara. Per le azioni dentro le righe di un
elenco, dove cinquanta pulsanti pieni urlerebbero.
La dimensione `sm` e' quella delle righe di elenco; `icona` toglie il testo e
lascia il quadrato.
Le regole stanno fuori da @layer, quindi vincono sulle utility di Tailwind:
un pulsante non puo' essere ritoccato per meta' con le classi. */
.tm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 40px;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid transparent;
font-size: 14px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.tm-btn svg {
width: 16px;
height: 16px;
flex: none;
}
/* Un anello unico per ogni variante: il colore del focus non deve dipendere
da quello del pulsante, altrimenti su rosso o su bianco cambia visibilita'
proprio dove serve di piu'. Il doppio contorno (chiaro + scuro) lo tiene
leggibile sia sulle card bianche sia sui riquadri colorati. */
.tm-btn:focus-visible,
.tm-console-btn:focus-visible,
.tm-nav-zona:focus-visible {
outline: 3px solid #1d4ed8;
outline-offset: 2px;
box-shadow: 0 0 0 5px #fff;
}
.tm-btn:disabled,
.tm-btn[aria-disabled="true"] {
opacity: .45;
cursor: not-allowed;
}
.tm-btn-primary { background: #4f46e5; border-color: #3730a3; color: #fff; }
.tm-btn-primary:hover:not(:disabled) { background: #4338ca; }
.tm-btn-danger { background: #dc2626; border-color: #991b1b; color: #fff; font-weight: 600; }
.tm-btn-danger:hover:not(:disabled) { background: #b91c1c; }
.tm-btn-secondary { background: #fff; border-color: #d1d5db; color: #374151; }
.tm-btn-secondary:hover:not(:disabled) { background: #f9fafb; border-color: #9ca3af; }
.tm-btn-ghost {
background: transparent;
border-color: transparent;
color: #4b5563;
min-height: 32px;
padding: 4px 8px;
font-size: 13px;
text-decoration: underline;
}
.tm-btn-ghost:hover:not(:disabled) { color: #111827; background: #f3f4f6; }
/* Tono tenue: stessa gerarchia, voce piu' bassa. Nelle righe di un elenco i
pulsanti sono tanti quanti i record, e il pieno li trasforma in rumore —
soprattutto il rosso, che smette di significare "attenzione" quando compare
cinquanta volte di fila. */
.tm-btn-tenue { border-color: transparent; font-weight: 600; }
.tm-btn-tenue.tm-btn-primary { background: #e0e7ff; color: #4338ca; }
.tm-btn-tenue.tm-btn-primary:hover:not(:disabled) { background: #c7d2fe; }
.tm-btn-tenue.tm-btn-danger { background: #fee2e2; color: #991b1b; }
.tm-btn-tenue.tm-btn-danger:hover:not(:disabled) { background: #fecaca; }
.tm-btn-tenue.tm-btn-secondary { background: #f3f4f6; color: #374151; }
.tm-btn-tenue.tm-btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
/* Tono velato: per i comandi che stanno SOPRA una superficie colorata — oggi
solo la testata, che ha il suo gradiente. Un pulsante bianco o indaco lì sopra
sarebbe una toppa; qui il colore lo dà lo sfondo e il pulsante si limita a
velarlo. Il focus diventa bianco, perche' l'anello blu su indaco non si
distingue. */
.tm-btn-velato {
background: rgba(255, 255, 255, .12);
border-color: transparent;
color: rgba(255, 255, 255, .92);
}
.tm-btn-velato:hover:not(:disabled) { background: rgba(255, 255, 255, .22); color: #fff; }
.tm-btn-velato:focus-visible {
outline: 3px solid #fff;
outline-offset: 2px;
box-shadow: 0 0 0 5px rgba(0, 0, 0, .35);
}
.tm-btn-sm { min-height: 32px; padding: 6px 12px; font-size: 13px; gap: 4px; }
.tm-btn-sm svg { width: 14px; height: 14px; }
/* Solo icona: quadrato, senza testo. Serve sempre un aria-label, altrimenti chi
usa un lettore di schermo trova un pulsante senza nome. */
.tm-btn-icona { padding-left: 8px; padding-right: 8px; }
.tm-btn-sm.tm-btn-icona { padding-left: 7px; padding-right: 7px; }
/* ─── Zone della pagina ────────────────────────────────────────────────────
Ogni sezione e' un bersaglio di navigazione: premendo un pulsante o una voce
dell'indice la pagina ci si porta sopra e la zona lampeggia, cosi' e' chiaro
*dove* e' finita l'attenzione invece di dover indovinare cosa e' cambiato.
scroll-margin-top tiene la testata della zona sotto la barra dell'indice,
che e' appiccicata in alto: senza, l'ancora finirebbe coperta. */
.tm-zona {
scroll-margin-top: 84px;
}
/* Lampeggio d'arrivo. Non usa `outline` perche' si somma male ai bordi delle
card; box-shadow non occupa spazio e non fa saltare il layout. */
.tm-zona-evidenziata {
animation: tmEvidenzia 1.6s ease-out;
}
@keyframes tmEvidenzia {
0% { box-shadow: 0 0 0 0 rgba(29, 78, 216, .6); }
12% { box-shadow: 0 0 0 7px rgba(29, 78, 216, .35); }
100% { box-shadow: 0 0 0 0 rgba(29, 78, 216, 0); }
}
/* Zona con un'operazione in corso: resta marcata finche' dura, cosi' anche
tornando sulla pagina dopo un ricaricamento si vede subito chi sta lavorando. */
.tm-zona-attiva {
box-shadow: 0 0 0 2px #4f46e5;
}
/* Chi ha chiesto meno animazioni riceve comunque il segnale, fermo. */
@media (prefers-reduced-motion: reduce) {
.tm-zona-evidenziata { animation: none; box-shadow: 0 0 0 3px rgba(29, 78, 216, .5); }
.tm-btn { transition: none; }
}
/* Indice delle zone, appiccicato in alto. */
.tm-nav-zone {
position: sticky;
top: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 12px;
margin: -4px 0 0;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: rgba(255, 255, 255, .92);
backdrop-filter: blur(6px);
}
.tm-nav-zona {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #fff;
color: #374151;
font-size: 12px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
.tm-nav-zona:hover { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; }
.tm-nav-zona-pericolo { color: #991b1b; border-color: #fecaca; }
.tm-nav-zona-pericolo:hover { background: #fef2f2; border-color: #fca5a5; color: #7f1d1d; }
/* ─── Console delle operazioni ─────────────────────────────────────────────
Una sola finestra per tutte le operazioni lunghe, agganciata in fondo alla
finestra del browser: quello che sta succedendo non deve uscire dallo schermo
mentre si scorre la pagina. Chiusa e' una striscia alta 44px.
Il posto in cui vive e' il corpo della pagina, ma la posizione e' fissa,
quindi in fondo alla pagina c'e' uno spaziatore della stessa altezza: senza,
coprirebbe l'ultima zona — che e' quella pericolosa. */
.tm-console {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 45;
display: flex;
flex-direction: column;
background: #0b1020;
border-top: 1px solid #1f2937;
box-shadow: 0 -8px 24px -12px rgba(0, 0, 0, .5);
color: #e5e7eb;
}
.tm-console-testata {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
min-height: 44px;
}
.tm-console-titolo {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: 0;
padding: 0;
color: #e5e7eb;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tm-console-btn {
background: transparent;
border: 1px solid #374151;
border-radius: 8px;
padding: 4px 10px;
color: #d1d5db;
font-size: 12px;
cursor: pointer;
}
.tm-console-btn:hover { background: #1f2937; color: #fff; }
/* Il corpo scorre; l'altezza e' in vh perche' su un portatile basso una
finestra fissa in pixel si mangerebbe mezza pagina. */
.tm-console-corpo {
max-height: min(38vh, 340px);
overflow-y: auto;
padding: 4px 12px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.55;
}
.tm-console-riga { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
.tm-console-ora { color: #6b7280; flex: none; }
.tm-console-sorgente { color: #818cf8; flex: none; }
.tm-console-riga-errore .tm-console-testo { color: #fca5a5; font-weight: 600; }
.tm-console-riga-ok .tm-console-testo { color: #6ee7b7; }
.tm-console-riga-attesa .tm-console-testo { color: #fcd34d; }
/* Barra di avanzamento nella testata: sottile, sempre allo stesso posto. */
.tm-console-barra {
flex: 1;
height: 6px;
min-width: 60px;
border-radius: 999px;
background: #1f2937;
overflow: hidden;
}
.tm-console-barra > div {
height: 100%;
border-radius: 999px;
background: #6366f1;
transition: width .3s ease;
}
.tm-console-barra-ok > div { background: #10b981; }
.tm-console-barra-errore > div { background: #ef4444; }
/* "Sei tornato indietro a leggere": lo scorrimento automatico si sospende e
compare il modo per riagganciarsi. */
.tm-console-torna {
align-self: center;
margin: 0 12px 8px;
background: #312e81;
border: 1px solid #4f46e5;
border-radius: 999px;
padding: 3px 12px;
color: #e0e7ff;
font-size: 11px;
cursor: pointer;
}
@media (max-width: 640px) {
.tm-console-corpo { max-height: 45vh; font-size: 11px; }
.tm-console-sorgente { display: none; }
}
/* ─── Responsive list tables ───────────────────────────────────────────────
Under 640px (a phone held in portrait; rotate to landscape and the width
grows past the breakpoint, restoring the normal table) every table with the

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>