Files
termanager2/resources/views/livewire/settings/xml-exchange.blade.php
Francesco Picone 426a95211d Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:01:41 +02:00

540 lines
31 KiB
PHP

{{--
Pagina "Backup e import".
Cinque zone numerate, in ordine di quanto sono definitive:
1 Backup completo (BackupManager)
2 Ripristina da backup (BackupManager)
3 Import PDF territori (qui)
4 Dati in formato XML (qui)
5 Zona pericolosa (ZonaPericolosa)
Ogni zona ha un'ancora, e l'indice in cima ci porta sopra evidenziandola:
con cinque riquadri lunghi, arrivare "circa dalle parti giuste" scorrendo
non basta a capire dove si e' atterrati.
Backup, ripristino e azzeramento stanno in componenti a se' perche' hanno
uno stato proprio e un poll fitto che non deve far ri-renderizzare tutta la
pagina — inclusa la tabella degli esiti dell'import, che puo' avere
centinaia di righe.
L'avanzamento e i log di TUTTE le operazioni finiscono in una sola console,
agganciata in fondo alla finestra. Qui dentro non ci sono piu' riquadri di
log: ce n'erano cinque, e la stessa operazione si raccontava in tre punti
diversi della pagina.
--}}
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Backup e import</h1>
<p class="text-sm text-gray-500 mt-1">
Backup completo dell&rsquo;applicazione, ripristino, import dei PDF dei territori,
scambio dati in XML e azzeramento.
</p>
</div>
{{-- ─── Indice delle zone ──────────────────────────────────────────────
Sposta la pagina e fa lampeggiare la zona, poi porta il focus da
tastiera sul primo comando: chi naviga senza mouse deve trovarsi
dentro la zona, non doverci arrivare a furia di tabulazioni. --}}
{{-- x-data vuoto ma necessario: x-on non funziona senza uno scope Alpine
sopra di sé, e queste voci non ne avrebbero altrimenti nessuno. --}}
<nav class="tm-nav-zone" aria-label="Zone della pagina" x-data="{}">
<span class="text-xs text-gray-500 self-center mr-1">Vai a</span>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-backup', true)">1 &middot; Backup</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-ripristino', true)">2 &middot; Ripristino</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-pdf', true)">3 &middot; Import PDF</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-xml', true)">4 &middot; XML</button>
<button type="button" class="tm-nav-zona tm-nav-zona-pericolo" x-on:click="window.tmFocusZona('zona-azzeramento', true)">5 &middot; Zona pericolosa</button>
</nav>
{{-- ═══ Zone 1 e 2 ════════════════════════════════════════════════════ --}}
<livewire:settings.backup-manager />
{{-- ═══ 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,
{{-- La cartella viaggia a lotti: vedi XmlExchange::getFilePerLottoProperty().
Il tetto in byte tiene ogni richiesta lontana dai 100 MB
che Cloudflare accetta per un singolo caricamento. --}}
fileCartella: [],
cartellaInCorso: false,
caricatiCartella: 0,
filePerLotto: {{ $this->filePerLotto }},
byteMassimiLotto: 40 * 1000 * 1000,
log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'import-pdf' });
},
/*
* Stessa regola di TerritorioPdfImportDispatcher::daIgnorare():
* Thumbs.db, desktop.ini, .DS_Store e gli altri file di sistema
* o nascosti non partono nemmeno. Il server li scarta comunque,
* ma caricarli sarebbe solo traffico.
*/
daIgnorare(file) {
const segmenti = (file.webkitRelativePath || file.name).split('/').filter((s) => s !== '');
if (segmenti.some((s) => s.startsWith('.') || s.toUpperCase() === '__MACOSX')) {
return true;
}
const nome = segmenti[segmenti.length - 1] || '';
return nome.startsWith('~$') || !nome.toLowerCase().endsWith('.pdf');
},
cartellaScelta(event) {
const tutti = Array.from(event.target.files);
this.fileCartella = tutti.filter((file) => !this.daIgnorare(file));
this.fileScelti = this.fileCartella.length;
this.caricatiCartella = 0;
const ignorati = tutti.length - this.fileCartella.length;
if (tutti.length > 0) {
this.log(`Cartella scelta: ${tutti.length} file, ${this.fileScelti} PDF da importare`
+ (ignorati > 0 ? ` (${ignorati} ignorati: file di sistema, nascosti o non PDF).` : `.`));
}
},
lotti() {
const lotti = [];
let lotto = [];
let byte = 0;
for (const file of this.fileCartella) {
if (lotto.length > 0 && (lotto.length >= this.filePerLotto || byte + file.size > this.byteMassimiLotto)) {
lotti.push(lotto);
lotto = [];
byte = 0;
}
lotto.push(file);
byte += file.size;
}
if (lotto.length > 0) {
lotti.push(lotto);
}
return lotti;
},
/*
* Un lotto alla volta, aggiunto (append) a quelli gia' arrivati.
* La guardia esiste perche' Livewire non ascolta gli errori di
* rete del caricamento: una connessione caduta lascerebbe la
* promessa appesa per sempre, con la pagina muta.
*/
caricaLotto(lotto) {
return new Promise((ok, ko) => {
let ultimoSegno = Date.now();
const guardia = setInterval(() => {
if (Date.now() - ultimoSegno > 120000) {
clearInterval(guardia);
this.$wire.cancelUpload('pdfFolder');
ko(new Error(`nessun avanzamento da due minuti`));
}
}, 5000);
this.$wire.uploadMultiple('pdfFolder', lotto,
() => { clearInterval(guardia); ok(); },
() => { clearInterval(guardia); ko(new Error(`il server ha rifiutato il lotto`)); },
() => { ultimoSegno = Date.now(); },
() => { clearInterval(guardia); ko(new Error(`caricamento annullato`)); },
true);
});
},
async inviaCartella() {
if (this.fileCartella.length === 0 || this.cartellaInCorso) {
return;
}
this.$dispatch('console-apri');
if (!(await this.$wire.cartellaPuoPartire())) {
this.log(`Import non avviato: c'è già un'operazione in corso.`, 'errore');
return;
}
this.cartellaInCorso = true;
this.caricatiCartella = 0;
try {
await this.$wire.svuotaCartella();
const lotti = this.lotti();
this.log(`Caricamento di ${this.fileCartella.length} PDF in ${lotti.length} lotti...`);
for (let i = 0; i < lotti.length; i++) {
await this.caricaLotto(lotti[i]);
this.caricatiCartella += lotti[i].length;
this.log(`Lotto ${i + 1}/${lotti.length} caricato: ${this.caricatiCartella}/${this.fileCartella.length} file.`);
}
this.log(`Cartella caricata. Preparazione import sul server...`, 'ok');
if (await this.$wire.importTerritoryPdfFolder()) {
this.fileCartella = [];
this.fileScelti = 0;
this.$refs.cartella.value = '';
} else {
this.log(`Import non avviato: il motivo è nell'avviso a comparsa. La cartella resta scelta, puoi ripremere.`, 'errore');
}
} catch (errore) {
this.log(`ERRORE durante il caricamento della cartella: ${errore.message}. Nessun import è partito: riprova.`, 'errore');
} finally {
this.cartellaInCorso = false;
}
},
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`);
}
},
inviaZip(event) {
const form = event.target;
this.zipInCorso = true;
this.zipPercento = 0;
this.$dispatch('console-apri');
this.log(`Invio dell'archivio ZIP al server...`);
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
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}%`);
}
});
xhr.addEventListener('load', () => {
this.zipInCorso = false;
let payload = {};
try {
payload = JSON.parse(xhr.responseText || '{}');
} catch (errore) {
payload = {};
}
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;
}
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');
});
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));
},
}">
<div>
<h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 &middot; Import PDF territori</h2>
<p class="text-xs text-gray-500 mt-1">
Associa i PDF ai territori gi&agrave; presenti e ne genera le miniature. Il nome del file deve
contenere il numero del territorio: una lettera subito dopo il numero ne fa parte (&laquo;12 B&raquo;,
&laquo;12B&raquo; e &laquo;12-b&raquo; sono tutti il 12B, mai il 12). I file che non si abbinano con
certezza a un solo territorio vengono saltati e segnalati nell&rsquo;esito.
</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>
<h3 class="text-sm font-semibold text-gray-900">Da archivio ZIP <span class="text-xs font-normal text-emerald-700">&mdash; consigliato</span></h3>
<p class="mt-1 text-xs text-gray-500">Un solo file: il server estrae i PDF da s&eacute;. &Egrave; la via da usare per grandi volumi.</p>
</div>
<div>
<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 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 --}}
{{-- Niente wire:model sull'input: con wire:model Livewire spedisce
tutta la cartella in una sola richiesta, e PHP ne tiene solo i
primi max_file_uploads (20). La spedisce inviaCartella(), a lotti. --}}
<form @submit.prevent="inviaCartella()" 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">I PDF partono a gruppi di {{ $this->filePerLotto }}; i file che non sono PDF (Thumbs.db, desktop.ini&hellip;) vengono ignorati.</p>
</div>
<div>
<label for="pdfFolder" class="sr-only">Cartella di PDF</label>
<input id="pdfFolder" type="file" multiple webkitdirectory directory
x-ref="cartella"
data-file-per-lotto="{{ $this->filePerLotto }}"
x-on:change="cartellaScelta($event)"
x-bind:disabled="cartellaInCorso"
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="cartellaInCorso ? caricatiCartella + ' / ' + fileScelti + ' PDF caricati' : fileScelti + ' PDF da importare'"></p>
<x-btn variante="secondary" type="submit"
x-bind:disabled="fileScelti === 0 || cartellaInCorso">
Importa cartella
</x-btn>
<span wire:loading wire:target="importTerritoryPdfFolder" class="text-xs text-indigo-700">Preparazione import...</span>
</form>
</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>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-medium text-gray-800">Esito dell&rsquo;import</div>
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold
{{ match($pdfImportStatus) {
'queued' => 'bg-amber-100 text-amber-800',
'running' => 'bg-blue-100 text-blue-800',
'completed' => 'bg-green-100 text-green-800',
'failed' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-700',
} }}">
{{ match($pdfImportStatus) {
'queued' => 'In coda',
'running' => 'In esecuzione',
'completed' => 'Completato',
'failed' => 'Fallito',
default => 'Inattivo',
} }}
</span>
</div>
@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
@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>
@endif
</section>
{{-- ═══ Zona 4 — Dati in formato XML ══════════════════════════════════ --}}
<section id="zona-xml"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5"
aria-labelledby="titolo-xml">
<div>
<h2 id="titolo-xml" class="text-lg font-semibold text-gray-900">4 &middot; Dati in formato XML</h2>
<p class="text-xs text-gray-500 mt-1">
Scambio dei soli dati gestionali, senza i file caricati. Per un backup davvero completo
usa il pacchetto .zip della <button type="button" class="underline" x-on:click="window.tmFocusZona('zona-backup', true)">zona 1</button>:
ripristinando solo l&rsquo;XML i territori restano senza PDF n&eacute; miniature.
</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
{{-- 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="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei
proclamatori in chiaro: chi lo ottiene pu&ograve; tentare di forzare le password offline.
</div>
<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>
{{-- 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. I PDF e le miniature dei territori sostituiti che il file non
richiama pi&ugrave; vengono eliminati.
</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>
{{-- Conversione legacy: e' una tantum, quindi sta in fondo alla zona e
non si prende una colonna accanto alle due operazioni ricorrenti. --}}
<details class="rounded-lg border border-gray-200 p-4">
<summary class="cursor-pointer text-sm font-semibold text-gray-900">Converti un dump SQL della vecchia applicazione</summary>
<p class="mt-2 text-xs text-gray-500">
Carica il file SQL (per esempio <code class="rounded bg-gray-100 px-1">dump-termanager-202604071526.sql</code>)
e ottieni un XML compatibile con l&rsquo;import qui sopra. Non scrive niente nell&rsquo;applicazione:
produce solo un file da scaricare.
</p>
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-3">
@csrf
<div>
<label for="sqlDump" class="sr-only">Dump SQL da convertire</label>
<input id="sqlDump" name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
@error('sqlDump') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<x-btn variante="secondary" type="submit">Converti in XML</x-btn>
</form>
</details>
{{-- Esito dell'import XML. L'import e' sincrono (una POST normale), quindi
non ha un flusso di log: ha un riepilogo, e sta qui accanto al
pulsante che lo ha prodotto. --}}
@if(!empty($importStats))
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-3">
<div class="text-sm font-medium text-gray-800">Esito dell&rsquo;ultimo import XML</div>
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
<div>Utenti importati: <strong>{{ $importStats['utenti_importati'] ?? 0 }}</strong></div>
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
<div>Territori importati: <strong>{{ $importStats['territori_importati'] ?? 0 }}</strong></div>
<div>Anni importati: <strong>{{ $importStats['anni_importati'] ?? 0 }}</strong></div>
<div>Campagne importate: <strong>{{ $importStats['campagne_importate'] ?? 0 }}</strong></div>
<div>Assegnazioni importate: <strong>{{ $importStats['assegnazioni_importate'] ?? 0 }}</strong></div>
<div>Territori duplicati saltati: <strong>{{ $importStats['duplicate_territori'] ?? 0 }}</strong></div>
<div>Assegnazioni saltate: <strong>{{ $importStats['assegnazioni_saltate'] ?? 0 }}</strong></div>
</div>
<x-btn variante="secondary" dimensione="sm" wire:click="downloadImportLogPdf">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Scarica il riepilogo in PDF
</x-btn>
@if(!empty($importIssues))
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white" style="max-height:260px;">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Entit&agrave;</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Legacy ID</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($importIssues as $issue)
<tr>
<td data-label="Entità" class="px-3 py-2 text-gray-900">{{ $issue['entity'] }}</td>
<td data-label="Legacy ID" class="px-3 py-2 text-gray-600">{{ $issue['legacy_id'] }}</td>
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['reason'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
</section>
{{-- ═══ Zona 5 ════════════════════════════════════════════════════════ --}}
<livewire:settings.zona-pericolosa />
{{-- Console unica, agganciata in fondo alla finestra. --}}
@include('livewire.settings.partials.console-operazioni')
</div>