++ fix e controlli
This commit is contained in:
@@ -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')
|
||||
— {{ $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’archivio è pronto e il download è partito.</strong>
|
||||
Non è stato cancellato ancora nulla: l’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’archivio, non può sapere se è arrivato intero: quella verifica tocca a te,
|
||||
ed è l’ultima possibilità 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’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'] ?? '' }} —
|
||||
{{ 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 · Backup completo (.zip)</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Un unico pacchetto con <strong>tutti</strong> i dati dell’applicazione — utenti compresi —
|
||||
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
|
||||
E’ l’archivio da conservare per rimettere in piedi l’installazione da zero.
|
||||
È l’archivio da conservare per rimettere in piedi l’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’archivio si genera in background: puoi seguire l’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'] ?? '' }} —
|
||||
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($occupato)
|
||||
<span class="text-xs text-gray-500">
|
||||
Un’operazione è già in corso: l’avanzamento è nella console in fondo.
|
||||
</span>
|
||||
@else
|
||||
<span class="text-xs text-gray-500">L’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 · 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’ da
|
||||
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono più 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’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’altra operazione è 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’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’è adesso.</li>
|
||||
<li>L’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 — come un’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’<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>
|
||||
|
||||
@@ -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 ↓
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Backup completo dell’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 · Backup</button>
|
||||
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-ripristino', true)">2 · Ripristino</button>
|
||||
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-pdf', true)">3 · Import PDF</button>
|
||||
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-xml', true)">4 · XML</button>
|
||||
<button type="button" class="tm-nav-zona tm-nav-zona-pericolo" x-on:click="window.tmFocusZona('zona-azzeramento', true)">5 · 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 · Import PDF territori</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Associa i PDF ai territori già presenti e ne genera le miniature. Il nome del file può
|
||||
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">— consigliato</span></h3>
|
||||
<p class="mt-1 text-xs text-gray-500">Un solo file: il server estrae i PDF da sé. È 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é.</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’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 · 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’XML i territori restano senza PDF né 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ò 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’import qui sopra. Non scrive niente nell’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’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à</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>
|
||||
|
||||
174
resources/views/livewire/settings/zona-pericolosa.blade.php
Normal file
174
resources/views/livewire/settings/zona-pericolosa.blade.php
Normal 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 · 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’archivio è pronto e il download è partito.</strong>
|
||||
Non è stato cancellato ancora nulla: l’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’archivio, non può sapere se è arrivato intero: quella verifica tocca a te,
|
||||
ed è l’ultima possibilità 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’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’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’è adesso.</li>
|
||||
<li>L’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 — come un’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’<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’azzeramento può partire solo a operazione finita.
|
||||
</span>
|
||||
@elseif($mioAzzeramento && ! $attesa)
|
||||
<span class="text-xs text-gray-600">
|
||||
Azzeramento in corso: l’avanzamento è 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user