++ Prima versione del designer piantine + Skill grafica

This commit is contained in:
2026-08-28 15:32:14 +02:00
parent 8aa29cc467
commit 7e4c8aa9b8
62 changed files with 16340 additions and 4 deletions

143
resources/js/piantine.js Normal file
View File

@@ -0,0 +1,143 @@
// Editor delle piantine (pagina /piantine, solo amministratore).
//
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, i poligoni
// dei territori già salvati e permette di disegnarne di nuovi con
// leaflet-draw. Parla con il componente Livewire attraverso eventi:
// JS → Livewire: 'poligono-disegnato', 'territorio-selezionato-mappa'
// Livewire → JS: 'piantine-dati-aggiornati'
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet-draw';
import 'leaflet-draw/dist/leaflet.draw.css';
const STILE_AREA = { color: '#2563eb', weight: 2, dashArray: '8 6', fill: false, interactive: false };
const STILE_SALVATO = { color: '#6b7280', weight: 2, fillColor: '#6b7280', fillOpacity: 0.12 };
const STILE_SELEZIONATO = { color: '#4f46e5', weight: 3, fillColor: '#6366f1', fillOpacity: 0.2 };
const STILE_IN_ATTESA = { color: '#d81b1b', weight: 3, fillColor: '#ef4444', fillOpacity: 0.15 };
function initPiantine() {
const contenitore = document.getElementById('piantine-map');
if (!contenitore || contenitore.dataset.inizializzato) {
return;
}
contenitore.dataset.inizializzato = '1';
let dati = { area: null, poligoni: [], selezionato: null };
try {
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
} catch {
// si parte comunque con la mappa vuota
}
// Etichette in italiano per la barra di disegno.
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il poligono di un territorio';
L.drawLocal.draw.toolbar.actions = { title: 'Annulla il disegno', text: 'Annulla' };
L.drawLocal.draw.toolbar.finish = { title: 'Concludi il disegno', text: 'Concludi' };
L.drawLocal.draw.toolbar.undo = { title: 'Rimuovi l\'ultimo punto', text: 'Ultimo punto' };
L.drawLocal.draw.handlers.polygon.tooltip = {
start: 'Clicca sulla mappa per iniziare il poligono.',
cont: 'Continua a cliccare lungo il confine.',
end: 'Clicca sul primo punto per chiudere il poligono.',
};
const mappa = L.map(contenitore, { zoomControl: true });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(mappa);
let layerArea = null;
const layerSalvati = L.featureGroup().addTo(mappa);
let layerInAttesa = null;
mappa.addControl(new L.Control.Draw({
draw: {
polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA },
polyline: false,
rectangle: false,
circle: false,
circlemarker: false,
marker: false,
},
edit: false,
}));
function ridisegna(nuoviDati, adattaVista) {
dati = nuoviDati;
if (layerArea) {
mappa.removeLayer(layerArea);
layerArea = null;
}
if (dati.area) {
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
}
layerSalvati.clearLayers();
for (const poligono of dati.poligoni) {
const selezionato = dati.selezionato === poligono.territorioId;
const layer = L.geoJSON(
{ type: 'Feature', properties: {}, geometry: poligono.geometry },
{ style: selezionato ? STILE_SELEZIONATO : STILE_SALVATO }
);
layer.bindTooltip(`Terr. ${poligono.numero}`, { sticky: true });
layer.on('click', () => {
window.Livewire.dispatch('territorio-selezionato-mappa', { territorioId: poligono.territorioId });
});
layerSalvati.addLayer(layer);
}
if (!adattaVista) {
return;
}
const riferimento = layerArea ?? (dati.poligoni.length ? layerSalvati : null);
if (riferimento) {
mappa.fitBounds(riferimento.getBounds().pad(0.08));
} else {
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
}
}
ridisegna(dati, true);
mappa.on(L.Draw.Event.CREATED, (evento) => {
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
}
layerInAttesa = evento.layer;
layerInAttesa.setStyle(STILE_IN_ATTESA);
layerInAttesa.addTo(mappa);
window.Livewire.dispatch('poligono-disegnato', { geometry: evento.layer.toGeoJSON().geometry });
});
let registrato = false;
document.addEventListener('livewire:init', registraEventi);
if (window.Livewire) {
registraEventi();
}
function registraEventi() {
if (registrato) {
return;
}
registrato = true;
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi }) => {
// Dopo un salvataggio il poligono provvisorio è diventato
// definitivo (o è stato scartato): via il tracciato rosso.
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
layerInAttesa = null;
}
ridisegna(nuovi, false);
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPiantine);
} else {
initPiantine();
}

BIN
resources/pdf/S-12_I.pdf Normal file

Binary file not shown.

View File

@@ -143,6 +143,11 @@
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
Tipologie
</a>
<a href="{{ route('piantine.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('piantine.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z"/></svg>
Piantine
</a>
<a href="{{ route('xml.exchange') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*', 'backup.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16v4m0 0l-3-3m3 3l3-3M4 12a8 8 0 1116 0v1a3 3 0 01-3 3h-1M7 16H6a2 2 0 01-2-2v-2"/></svg>

View File

@@ -0,0 +1,229 @@
<div>
@vite('resources/js/piantine.js')
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Piantine dei territori</h1>
<p class="mt-1 text-sm text-gray-600">
Carica il KML dell'area della città, disegna i poligoni dei territori sulla mappa e genera
immagini ad alta definizione e PDF sul modulo S-12. I PDF sostituiti finiscono nello storico
versioni, sempre ripristinabili.
</p>
</div>
{{-- ── 1. Area della città (KML) ─────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<h2 class="text-base font-semibold text-gray-900 mb-3">1 · Area della città</h2>
<div class="grid gap-4 lg:grid-cols-2">
<form wire:submit="caricaKml">
<label for="kmlFile" class="block text-sm font-medium text-gray-700">File .kml dell'area</label>
<div class="mt-1 flex items-center gap-3">
<input type="file" id="kmlFile" wire:model="kmlFile" accept=".kml,.xml"
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-indigo-700 hover:file:bg-indigo-100">
<x-btn variante="primary" type="submit" wire:loading.attr="disabled" wire:target="kmlFile,caricaKml">
Carica
</x-btn>
</div>
<div wire:loading wire:target="kmlFile" class="mt-1 text-xs text-gray-500">Caricamento del file…</div>
@error('kmlFile') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@if($config['kml_nome'])
<p class="mt-2 text-xs text-gray-500">
Area attiva: <span class="font-medium text-gray-700">{{ $config['kml_nome'] }}</span>
(aggiornata il {{ $config['aggiornato_il'] }}).
Caricare un nuovo KML sostituisce l'area; i poligoni già disegnati restano.
</p>
@else
<p class="mt-2 text-xs text-amber-600">Nessuna area caricata: la mappa parte senza perimetro di riferimento.</p>
@endif
</form>
<form wire:submit="salvaLocalita">
<label for="localita" class="block text-sm font-medium text-gray-700">
Località (intestazione del modulo S-12)
</label>
<div class="mt-1 flex items-center gap-3">
<input type="text" id="localita" wire:model="localita" placeholder="Es. Palermo — Congregazione …"
class="block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<x-btn variante="secondary" type="submit">Salva</x-btn>
</div>
<p class="mt-2 text-xs text-gray-500">Se vuota, viene usato il nome della congregazione dalle impostazioni.</p>
</form>
</div>
</div>
{{-- ── 2. Editor della mappa ─────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<h2 class="text-base font-semibold text-gray-900 mb-3">2 · Disegno dei territori</h2>
<div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-2">
<div wire:ignore id="piantine-map" class="w-full rounded-xl border border-gray-300 z-0" style="height: 65vh; min-height: 420px;"></div>
<p class="mt-2 text-xs text-gray-500">
Usa lo strumento poligono (in alto a sinistra sulla mappa) per disegnare il territorio.
Il tratteggio blu è l'area del KML; i poligoni grigi sono i territori già salvati.
</p>
</div>
<div>
<label for="territorioSelezionato" class="block text-sm font-medium text-gray-700">Territorio da associare</label>
<select id="territorioSelezionato" wire:model.live="territorioSelezionato"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<option value="">— scegli il territorio —</option>
@foreach($territori as $territorio)
<option value="{{ $territorio->id }}">
{{ $territorio->numero }}{{ $mappe->has($territorio->id) ? ' · poligono presente' : '' }}
</option>
@endforeach
</select>
@if($geometriaInAttesa)
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
C'è un poligono disegnato non ancora salvato.
</div>
<div class="mt-2 flex gap-2">
<x-btn variante="primary" wire:click="salvaPoligono" wire:loading.attr="disabled">
Salva poligono
</x-btn>
<x-btn variante="ghost" wire:click="scartaPoligono">Scarta</x-btn>
</div>
@else
<p class="mt-3 text-xs text-gray-500">
Disegna un poligono e poi salvalo sul territorio selezionato. Ridisegnare e salvare
sostituisce il poligono esistente.
</p>
@endif
{{-- Territori con poligono --}}
<div class="mt-4 border-t border-gray-200 pt-3">
<h3 class="text-sm font-semibold text-gray-800 mb-2">Territori con poligono ({{ $mappe->count() }})</h3>
<ul class="divide-y divide-gray-100 max-h-72 overflow-y-auto pr-1">
@forelse($mappe->sortBy(fn ($m) => [strlen($m->territorio?->numero ?? ''), $m->territorio?->numero]) as $mappa)
<li class="py-2 text-sm" wire:key="mappa-{{ $mappa->id }}">
<div class="flex items-center justify-between gap-2">
<button type="button" wire:click="$set('territorioSelezionato', {{ $mappa->territorio_id }})"
class="font-medium {{ $territorioSelezionato === $mappa->territorio_id ? 'text-indigo-700' : 'text-gray-800' }} hover:underline">
Terr. {{ $mappa->territorio?->numero ?? '?' }}
</button>
<span class="text-xs {{ $mappa->image_path ? 'text-green-600' : 'text-gray-400' }}">
{{ $mappa->image_path ? 'immagine del ' . $mappa->rendered_at?->format('d/m/Y H:i') : 'immagine da generare' }}
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<button type="button" wire:click="generaImmagine({{ $mappa->id }})"
wire:loading.attr="disabled" wire:target="generaImmagine({{ $mappa->id }})"
class="text-indigo-600 hover:underline disabled:opacity-50">
<span wire:loading.remove wire:target="generaImmagine({{ $mappa->id }})">Genera immagine</span>
<span wire:loading wire:target="generaImmagine({{ $mappa->id }})">Rendering in corso…</span>
</button>
@if($mappa->image_path)
<a href="{{ asset('storage/' . $mappa->image_path) }}" target="_blank" class="text-indigo-600 hover:underline">Scarica immagine</a>
<button type="button" wire:click="generaPdf({{ $mappa->id }})"
wire:loading.attr="disabled" wire:target="generaPdf({{ $mappa->id }})"
wire:confirm="Sostituire il PDF del territorio {{ $mappa->territorio?->numero }}? Quello attuale finisce nello storico versioni."
class="text-indigo-600 hover:underline disabled:opacity-50">
<span wire:loading.remove wire:target="generaPdf({{ $mappa->id }})">Genera PDF e sostituisci</span>
<span wire:loading wire:target="generaPdf({{ $mappa->id }})">Composizione…</span>
</button>
@endif
<button type="button" wire:click="eliminaPoligono({{ $mappa->id }})"
wire:confirm="Eliminare poligono e immagine del territorio {{ $mappa->territorio?->numero }}? Il PDF del territorio resta com'è."
class="text-red-600 hover:underline">Elimina</button>
</div>
</li>
@empty
<li class="py-2 text-sm text-gray-500">Nessun poligono ancora disegnato.</li>
@endforelse
</ul>
</div>
</div>
</div>
</div>
{{-- ── 3. Rigenerazione massiva ──────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4"
@if($stato && in_array($stato['status'], ['queued', 'running'], true)) wire:poll.2s @endif>
<h2 class="text-base font-semibold text-gray-900 mb-3">3 · Rigenerazione massiva</h2>
<p class="text-sm text-gray-600 mb-3">
Rigenera in coda le immagini di tutti i territori con poligono (utile dopo aggiornamenti della
cartografia). Con la seconda opzione vengono ricomposti e sostituiti anche i PDF.
</p>
<div class="flex flex-wrap gap-2">
<x-btn variante="secondary" wire:click="rigeneraTutto(false)"
wire:confirm="Rigenerare le immagini di tutti i territori con poligono?"
wire:loading.attr="disabled" :disabled="$stato && in_array($stato['status'], ['queued', 'running'], true)">
Rigenera tutte le immagini
</x-btn>
<x-btn variante="secondary" wire:click="rigeneraTutto(true)"
wire:confirm="Rigenerare immagini E sostituire tutti i PDF? Le versioni attuali finiscono nello storico."
wire:loading.attr="disabled" :disabled="$stato && in_array($stato['status'], ['queued', 'running'], true)">
Rigenera immagini + PDF
</x-btn>
</div>
@if($stato)
<div class="mt-4">
@php
$percento = ($stato['total'] ?? 0) > 0 ? (int) round(($stato['processed'] / $stato['total']) * 100) : 0;
@endphp
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
<span>
@if($stato['status'] === 'queued') In coda…
@elseif($stato['status'] === 'running') In corso: {{ $stato['processed'] }}/{{ $stato['total'] }}
@elseif($stato['status'] === 'completed') Completata: {{ $stato['processed'] }}/{{ $stato['total'] }} ({{ $stato['falliti'] }} fallite)
@else Fallita: {{ $stato['error'] }}
@endif
</span>
<span>{{ $percento }}%</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-100 overflow-hidden">
<div class="h-2 rounded-full transition-all duration-500 {{ $stato['status'] === 'failed' ? 'bg-red-500' : 'bg-indigo-500' }}"
style="width: {{ $percento }}%"></div>
</div>
@if(!empty($stato['logs']))
<div class="mt-3 max-h-48 overflow-y-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-gray-100 space-y-0.5">
@foreach($stato['logs'] as $riga)
<div>{{ $riga }}</div>
@endforeach
</div>
@endif
</div>
@endif
</div>
{{-- ── 4. Storico versioni PDF ───────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<h2 class="text-base font-semibold text-gray-900 mb-3">4 · Storico versioni PDF</h2>
@if($versioni->isEmpty())
<p class="text-sm text-gray-500">Nessun PDF è ancora stato sostituito.</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-xs uppercase tracking-wider text-gray-500 border-b border-gray-200">
<th class="py-2 pr-4">Territorio</th>
<th class="py-2 pr-4">Sostituito il</th>
<th class="py-2 pr-4">Origine</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($versioni as $versione)
<tr wire:key="versione-{{ $versione->id }}">
<td class="py-2 pr-4 font-medium text-gray-800">{{ $versione->territorio?->numero ?? '?' }}</td>
<td class="py-2 pr-4 text-gray-600">{{ $versione->sostituito_il?->format('d/m/Y H:i') }}</td>
<td class="py-2 pr-4 text-gray-600">{{ $versione->origine }}</td>
<td class="py-2 text-right space-x-3 whitespace-nowrap">
<a href="{{ asset('storage/' . $versione->pdf_path) }}" target="_blank" class="text-indigo-600 hover:underline text-xs">Apri</a>
<button type="button" wire:click="ripristinaVersione({{ $versione->id }})"
wire:confirm="Ripristinare questa versione per il territorio {{ $versione->territorio?->numero }}? Il PDF attuale finisce a sua volta nello storico."
class="text-indigo-600 hover:underline text-xs">Ripristina</button>
<button type="button" wire:click="eliminaVersione({{ $versione->id }})"
wire:confirm="Eliminare definitivamente questa versione archiviata?"
class="text-red-600 hover:underline text-xs">Elimina</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
<script type="application/json" id="piantine-dati">@json($datiMappaIniziali)</script>
</div>