Mapa: panel de fases con acceso a gestionar capas y progreso, editor funcional, saveFeatureProgress, onTemplateChange

This commit is contained in:
2026-05-09 21:30:46 +02:00
parent 2a300241f9
commit dabd35091a
2 changed files with 248 additions and 193 deletions
@@ -3,49 +3,62 @@
<!-- Columna izquierda: Mapa -->
<div x-show="!formFullscreen" x-cloak class="w-full lg:w-2/3 relative">
<div id="map" style="height: 100%; min-height: 500px; width: 100%;" wire:ignore></div>
<div class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-56 border border-base-300 text-sm"> <!-- w-48 -->
<h3 class="font-semibold text-base mb-2">Capas del proyecto</h3>
<div class="space-y-2">
<!-- Panel lateral de capas -->
<div class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
<h3 class="font-semibold text-base mb-2">Fases y capas</h3>
<div class="space-y-3">
@foreach($phases as $phase)
<label class="flex items-center gap-2 text-sm">
<input type="checkbox"
wire:change="toggleLayer({{ $phase->id }})"
@if(in_array($phase->id, $activeLayers)) checked @endif
class="toggle toggle-xs">
<span style="color: {{ $phase->color }};" class="text-base"></span>
<span class="flex-1">{{ $phase->name }}</span>
<span class="badge badge-xs">{{ $phase->progress_percent }}%</span>
</label>
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
<div class="flex items-center gap-2">
<input type="checkbox"
wire:change="toggleLayer({{ $phase->id }})"
@if(in_array($phase->id, $activeLayers)) checked @endif
class="toggle toggle-xs toggle-primary">
<span style="color: {{ $phase->color }};" class="text-lg"></span>
<span class="flex-1 font-medium text-sm truncate">{{ $phase->name }}</span>
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}%</span>
</div>
{{-- Capas de esta fase --}}
@if($phase->layers->isNotEmpty())
<div class="ml-7 mt-1 space-y-1">
@foreach($phase->layers as $layer)
<div class="flex items-center gap-1 text-xs text-gray-600">
<span class="w-2 h-2 rounded-full inline-block" style="background: {{ $layer->color ?? '#ccc' }}"></span>
<span class="flex-1 truncate">{{ $layer->name }}</span>
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} elem.</span>
</div>
@endforeach
</div>
@endif
{{-- Botón para ir a gestión de capas de esta fase --}}
<div class="mt-1 ml-7">
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary">
✏️ Gestionar capas
</a>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
📊 Progreso
</a>
</div>
</div>
@endforeach
</div>
<div class="mt-4">
<button wire:click="openLayerModal" class="btn btn-sm btn-primary w-full">
📂 Gestión de capas
{{-- Botones generales --}}
<div class="mt-3 space-y-1">
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
📍 Centrar mapa
</button>
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full mt-2">
📍 Centrar en proyecto
</button>
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full mt-2">
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
🧭 Mi ubicación
</button>
</div>
</div>
</div>
<!-- Modal de Gestión de Capas -->
@if($showLayerModal)
<div class="fixed inset-0 bg-black bg-opacity-50 z-[2000] flex items-center justify-center p-4">
<div class="bg-base-100 p-6 rounded-box shadow-2xl w-full max-w-lg">
<h3 class="font-bold text-lg mb-4">Gestión de capas</h3>
<p class="text-sm mb-4">Configura las capas y elementos visibles del proyecto.</p>
<div class="modal-action">
<button wire:click="closeLayerModal" class="btn btn-sm btn-primary">Cerrar</button>
</div>
</div>
</div>
@endif
<!-- Columna derecha: Editor de progreso -->
<!-- Columna derecha: Editor de progreso / inspecciones -->
<div class="w-full lg:w-1/3 transition-all duration-300" :class="{'lg:w-full': formFullscreen}">
<div class="card bg-base-100 shadow-xl h-full flex flex-col">
<div class="card-body overflow-y-auto flex-1">
@@ -53,74 +66,116 @@
<h2 class="card-title">Editor</h2>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa">
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
<x-letsicon-full-screen-corner-light />
</button>
</div>
@if($selectedTemplateId)
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
@if($template)
@foreach($template->fields as $field)
<div class="mb-3">
<label class="label-text">{{ $field['label'] }} @if($field['required'])<span class="text-error">*</span>@endif</label>
@switch($field['type'])
@case('percentage')
<div class="flex items-center gap-2">
<input type="number"
wire:model="inspectionFormData.{{ $field['name'] }}"
min="0" max="100" step="1"
class="input input-bordered w-24" />
<span class="text-sm">%</span>
<input type="range" min="0" max="100" step="1"
wire:model.live="inspectionFormData.{{ $field['name'] }}"
class="range range-primary range-xs flex-1" />
@if($selectedFeature)
{{-- Feature seleccionado --}}
<div class="border rounded-lg p-3 mb-3 bg-base-200">
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? 'Elemento' }}</h3>
<p class="text-xs text-gray-500">Fase: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">Capa: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
{{-- Progreso --}}
<div class="form-control mb-3">
<label class="label-text">Progreso: {{ $editProgress }}%</label>
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
<div class="flex justify-between text-xs">
<span>0%</span><span>50%</span><span>100%</span>
</div>
</div>
<div class="form-control mb-3">
<label class="label-text">Responsable</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
</div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
💾 Guardar progreso
</button>
{{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty())
<div class="divider text-xs">Inspección</div>
<div class="form-control mb-2">
<label class="label-text">Plantilla</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">Seleccionar plantilla...</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
</div>
@if($selectedTemplateId && !empty($inspectionFormData))
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
@if($template)
@foreach($template->fields as $field)
<div class="mb-2">
<label class="label-text text-xs">{{ $field['label'] }} @if($field['required'] ?? false)<span class="text-error">*</span>@endif</label>
@switch($field['type'] ?? 'text')
@case('percentage')
<div class="flex items-center gap-1">
<input type="number" wire:model="inspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
<span class="text-xs">%</span>
<input type="range" min="0" max="100" wire:model.live="inspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
</div>
@break
@case('boolean')
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">Seleccionar</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
</select>
@break
@case('textarea')
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
@break
@default
<input type="{{ $field['type'] ?? 'text' }}" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
@endswitch
</div>
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">Registrar inspección</button>
@endif
@endif
{{-- Historial de inspecciones --}}
@if($inspectionHistory->isNotEmpty())
<div class="divider text-xs">Historial</div>
<div class="space-y-1 max-h-40 overflow-y-auto">
@foreach($inspectionHistory as $ins)
<div class="border rounded p-2 text-xs">
<div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? 'Inspección' }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@break
@case('boolean')
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox" />
@break
@case('date')
<input type="date" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered w-full" />
@break
@case('integer')
<input type="number" step="1"
wire:model="inspectionFormData.{{ $field['name'] }}"
min="{{ $field['min'] ?? '' }}" max="{{ $field['max'] ?? '' }}"
class="input input-bordered w-full" />
@break
@case('decimal')
<input type="number" step="{{ $field['step'] ?? 'any' }}"
wire:model="inspectionFormData.{{ $field['name'] }}"
min="{{ $field['min'] ?? '' }}" max="{{ $field['max'] ?? '' }}"
class="input input-bordered w-full" />
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered w-full">
<option value="">Seleccione</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
</select>
@break
@case('textarea')
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="3" class="textarea textarea-bordered w-full"></textarea>
@break
@default
<input type="text" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered w-full" />
@endswitch
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-sm w-full">Registrar inspección</button>
@endif
@else
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">Sin plantillas</h3>
<div class="text-xs">Crea una plantilla de inspección para este proyecto.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">Crear</a>
</div>
@endif
@else
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">No hay templates creados</h3>
<div class="text-xs">No hay templates creados. Presiona "Nuevo template" para comenzar.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-principal btn-sm">Crear Template</a>
<div class="text-center text-gray-400 py-8">
<p class="text-lg">👆</p>
<p>Haz clic en un elemento del mapa para editarlo</p>
</div>
@endif
</div>
@@ -130,33 +185,25 @@
@push('scripts')
<style>
.leaflet-container {
z-index: 0 !important;
}
.leaflet-container { z-index: 0 !important; }
</style>
<script>
document.addEventListener('livewire:init', function () {
setTimeout(() => initMap(), 100);
});
let map;
let layers = {};
const layers = {};
function initMap() {
map = L.map('map').setView([51.505, -0.09], 13);
if (map) return;
const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('map').setView(center, 16);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
}).addTo(map);
/*L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);*/
// Add geojson layers for active phases - features loaded from DB via features() relationship
// Cargar fases y sus features
@foreach($phases as $phase)
@php
$phaseFeatures = $phase->features()->with('layer')->get();
$phaseFeatures = $phase->features()->with('layer.phase')->get();
$fc = [
'type' => 'FeatureCollection',
'features' => $phaseFeatures->map(function($f) {
@@ -175,107 +222,85 @@
})->values()->toArray()
];
@endphp
const phase{{ $phase->id }}Data = @json($fc);
if (phase{{ $phase->id }}Data && phase{{ $phase->id }}Data.features && phase{{ $phase->id }}Data.features.length > 0) {
layers[{{ $phase->id }}] = L.geoJSON(phase{{ $phase->id }}Data, {
style: { color: '{{ $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: function (feature, layer) {
const props = feature.properties || {};
const featId = props._feature_id || feature.id;
let content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 'N/A'}%<br>
Responsable: ${props.responsible || '-'}<br>
<input type="range" min="0" max="100" value="${props.progress || 0}" id="slider-${featId}" class="range range-sm" />
<button class="btn btn-xs btn-primary mt-1" onclick="window.updateFeatureProgress(${featId}, document.getElementById('slider-${featId}').value)">Actualizar</button>`;
layer.bindPopup(content);
layer.on('click', function(e) {
@this.selectFeature(featId);
});
}
});
if (@json(in_array($phase->id, $activeLayers))) {
layers[{{ $phase->id }}].addTo(map);
(function() {
const data = @json($fc);
if (data && data.features && data.features.length > 0) {
const phaseLayer = L.geoJSON(data, {
style: { color: '{{ $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: function(feature, layer) {
const props = feature.properties || {};
const featId = props._feature_id || feature.id;
let content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 0}%<br>
Responsable: ${props.responsible || '-'}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature(${featId})">✏️ Editar</button>`;
layer.bindPopup(content);
layer.on('click', function() { selectFeature(featId); });
}
});
layers[{{ $phase->id }}] = phaseLayer;
@if(in_array($phase->id, $activeLayers))
phaseLayer.addTo(map);
@endif
}
})();
@endforeach
// 🔁 Forzar que el mapa recalcule su tamaño (por si el contenedor no está visible al 100%)
setTimeout(() => {
map.invalidateSize();
// Llamar al zoom inicial una vez que todas las capas están cargadas y el mapa tiene tamaño correcto
zoomToAllFeatures();
}, 200); // pequeño retardo para garantizar que el DOM esté estable
}, 200);
}
// Función que ajusta el mapa a todos los elementos visibles
function zoomToAllFeatures() {
// Si no hay mapa inicializado, salir
if (!map) return;
const bounds = L.latLngBounds();
let hasValidBounds = false;
// Recorrer todas las capas almacenadas (por ID de fase)
for (let id in layers) {
const layer = layers[id];
// Verificar que la capa existe, está en el mapa y tiene el método getBounds
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const layerBounds = layer.getBounds();
// Si la capa tiene bounds válidos (no es un grupo vacío)
if (layerBounds.isValid()) {
bounds.extend(layerBounds);
hasValidBounds = true;
}
}
}
if (hasValidBounds) {
// Ajustar el mapa a los bounds combinados con un padding de 20px
map.fitBounds(bounds, { padding: [20, 20] });
} else {
// Fallback: centrar en el proyecto con zoom por defecto
map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
function zoomToAllFeatures() {
if (!map) return;
const bounds = L.latLngBounds();
let hasBounds = false;
for (let id in layers) {
const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds();
if (b.isValid()) { bounds.extend(b); hasBounds = true; }
}
}
if (hasBounds) map.fitBounds(bounds, { padding: [20, 20] });
else map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
}
// Listen for Livewire events
Livewire.on('layersUpdated', (activeIds) => {
for (let id in layers) {
if (activeIds.includes(parseInt(id))) {
if (!map.hasLayer(layers[id])) layers[id].addTo(map);
} else {
if (map.hasLayer(layers[id])) map.removeLayer(layers[id]);
}
}
});
Livewire.on('centerMap', () => {
zoomToAllFeatures();
});
Livewire.on('progressUpdated', (phaseId, newPercent) => {
// Optional: refresh layer content
});
Livewire.on('mapResize', () => {
if (map) {
setTimeout(() => map.invalidateSize(), 100);
}
});
function selectFeature(featureId) {
@this.selectFeature(featureId);
}
function getUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude];
L.marker(latlng).addTo(map).bindPopup('Tu ubicación actual').openPopup();
L.marker(latlng).addTo(map).bindPopup('Tu ubicación').openPopup();
map.setView(latlng, 16);
});
}, () => alert('No se pudo obtener la ubicación'));
} else {
alert('Geolocalización no soportada');
}
}
window.updateFeatureProgress = function(featureId, progress) {
@this.updateProgress(featureId, progress, 'Actualizado desde mapa');
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 100);
Livewire.on('layersUpdated', (activeIds) => {
const ids = Array.isArray(activeIds) ? activeIds : activeIds[0];
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) layers[id].addTo(map);
} else {
if (map.hasLayer(layers[id])) map.removeLayer(layers[id]);
}
}
zoomToAllFeatures();
});
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); });
});
</script>
@endpush