++ add reset program

This commit is contained in:
2026-07-29 14:26:36 +02:00
parent c6abef977d
commit 588910b893
14 changed files with 1524 additions and 183 deletions

View File

@@ -0,0 +1,296 @@
{{--
Backup, ripristino e azzeramento, con avanzamento reale.
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.
--}}
@php
$op = $operazione;
$tipo = $op['tipo'] ?? null;
$stato = $op['status'] ?? null;
$attesaDownload = ($op['fase'] ?? null) === \App\Services\BackupOperationState::FASE_ATTESA_DOWNLOAD;
$etichettaTipo = match ($tipo) {
'backup' => 'Backup in corso',
'restore' => 'Ripristino in corso',
'reset' => 'Azzeramento in corso',
default => 'Operazione in corso',
};
@endphp
<div class="space-y-6"
x-data="{
zipSelezionato: '',
uploadInCorso: false,
uploadPercento: 0,
erroreUpload: '',
inviaRipristino(event) {
const form = event.target;
const formData = new FormData(form);
this.erroreUpload = '';
this.uploadInCorso = true;
this.uploadPercento = 0;
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
this.uploadPercento = Math.round((e.loaded / e.total) * 100);
}
});
xhr.addEventListener('load', () => {
this.uploadInCorso = false;
let payload = {};
try { payload = JSON.parse(xhr.responseText || '{}'); } catch (e) { payload = {}; }
if (xhr.status >= 200 && xhr.status < 300 && payload.operation_id) {
this.uploadPercento = 100;
this.zipSelezionato = '';
form.reset();
// Da qui in poi l'avanzamento arriva dal job, non dall'upload.
$wire.seguiOperazione(payload.operation_id);
return;
}
this.erroreUpload = payload.message
|| (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.');
});
xhr.addEventListener('error', () => {
this.uploadInCorso = false;
this.erroreUpload = 'Errore di rete durante il caricamento del pacchetto.';
});
xhr.send(formData);
},
}"
x-on:scarica-backup.window="window.location.href = $event.detail.url">
{{-- ─── 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.750ms="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>
<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"
wire:click="confermaAzzeramento"
wire:confirm="Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente."
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">
<div>
<h2 class="text-lg font-semibold text-gray-900">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.
</p>
</div>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il pacchetto include gli utenti con le password (hash), i nomi dei
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>
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>
{{-- ─── Ripristino ─────────────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">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.
</p>
</div>
<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
considerarsi segrete. Verrai portato subito sulla schermata di cambio password.
</div>
<form action="{{ route('backup.restore') }}" method="POST" enctype="multipart/form-data" class="space-y-4"
@submit.prevent="if (confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.')) inviaRipristino($event)">
@csrf
<div>
<input name="backupZip" type="file" accept=".zip,application/zip"
x-on:change="zipSelezionato = $event.target.files[0] ? $event.target.files[0].name : ''; erroreUpload = ''"
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 x-show="erroreUpload" x-cloak class="text-red-500 text-xs mt-1" x-text="erroreUpload"></p>
</div>
{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}
<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>
<span class="tabular-nums" x-text="uploadPercento + '%'"></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-200" :style="'width:' + uploadPercento + '%'"></div>
</div>
</div>
<button type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso"
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) ? 'opacity:.5;cursor:not-allowed;' : ''">
Ripristina backup
</button>
</form>
</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 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>
</div>

View File

@@ -4,62 +4,12 @@
<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>
</div>
{{-- Backup completo: e' la sezione che serve nel 99% dei casi, quindi sta
in cima. Le sezioni XML piu' sotto restano per gli usi parziali e per
la conversione dei dump legacy. --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<h2 class="text-lg font-semibold text-gray-900">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.
</p>
</div>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il pacchetto include gli utenti con le password (hash), i nomi dei
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">
<a href="{{ route('backup.download') }}"
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;text-decoration:none;">
<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>
Scarica backup completo
</a>
<p class="text-xs text-gray-500">La preparazione dell&rsquo;archivio puo&rsquo; richiedere qualche minuto se ci sono molti PDF.</p>
</div>
<div class="border-t border-gray-200 pt-5">
<h3 class="text-sm font-semibold text-gray-900">Ripristina da backup</h3>
<p class="mt-1 text-xs text-gray-500">
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
gestionali correnti e tutti i file caricati.
</p>
<div class="mt-3 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
considerarsi segrete. Verrai portato subito sulla schermata di cambio password.
</div>
<form action="{{ route('backup.restore') }}" method="POST" enctype="multipart/form-data" class="mt-4 space-y-4"
onsubmit="return confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.');">
@csrf
<div>
<input name="backupZip" 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-red-50 file:text-red-700 hover:file:bg-red-100">
@error('backupZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="submit"
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;">
Ripristina backup
</button>
</form>
</div>
</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. --}}
<livewire:settings.backup-manager />
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
x-data="{