2026-05-07 23:31:33 +02:00
|
|
|
<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
|
|
|
|
|
class="flex flex-col lg:flex-row gap-0 h-screen p-1">
|
|
|
|
|
<!-- 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">
|
|
|
|
|
@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>
|
|
|
|
|
@endforeach
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-4">
|
2026-05-08 09:01:00 +02:00
|
|
|
<button wire:click="openLayerModal" class="btn btn-sm btn-primary w-full">
|
|
|
|
|
📂 Gestión de capas
|
|
|
|
|
</button>
|
|
|
|
|
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full mt-2">
|
2026-05-07 23:31:33 +02:00
|
|
|
📍 Centrar en proyecto
|
|
|
|
|
</button>
|
|
|
|
|
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full mt-2">
|
|
|
|
|
🧭 Mi ubicación
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-08 09:01:00 +02:00
|
|
|
<!-- 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
|
|
|
|
|
|
2026-05-07 23:31:33 +02:00
|
|
|
<!-- Columna derecha: Editor de progreso -->
|
|
|
|
|
<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">
|
|
|
|
|
<div class="flex justify-between items-center mb-2">
|
|
|
|
|
<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" />
|
|
|
|
|
</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
|
|
|
|
|
</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">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>
|
|
|
|
|
@endif
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@push('scripts')
|
|
|
|
|
<style>
|
|
|
|
|
.leaflet-container {
|
|
|
|
|
z-index: 0 !important;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<script>
|
|
|
|
|
document.addEventListener('livewire:init', function () {
|
|
|
|
|
setTimeout(() => initMap(), 100);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let map;
|
|
|
|
|
let layers = {};
|
|
|
|
|
|
|
|
|
|
function initMap() {
|
|
|
|
|
map = L.map('map').setView([51.505, -0.09], 13);
|
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
|
|
|
attribution: '© <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: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
|
|
|
}).addTo(map);*/
|
|
|
|
|
|
2026-05-08 01:16:20 +02:00
|
|
|
// Add geojson layers for active phases - features loaded from DB via features() relationship
|
2026-05-07 23:31:33 +02:00
|
|
|
@foreach($phases as $phase)
|
2026-05-08 01:16:20 +02:00
|
|
|
@php
|
|
|
|
|
$phaseFeatures = $phase->features()->with('layer')->get();
|
|
|
|
|
$fc = [
|
|
|
|
|
'type' => 'FeatureCollection',
|
|
|
|
|
'features' => $phaseFeatures->map(function($f) {
|
|
|
|
|
return [
|
|
|
|
|
'type' => 'Feature',
|
|
|
|
|
'id' => $f->id,
|
|
|
|
|
'geometry' => $f->geometry,
|
|
|
|
|
'properties' => array_merge($f->properties ?? [], [
|
|
|
|
|
'name' => $f->name,
|
|
|
|
|
'progress' => $f->progress,
|
|
|
|
|
'responsible' => $f->responsible,
|
|
|
|
|
'template_id' => $f->template_id,
|
|
|
|
|
'_feature_id' => $f->id,
|
|
|
|
|
])
|
|
|
|
|
];
|
|
|
|
|
})->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) {
|
2026-05-07 23:31:33 +02:00
|
|
|
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) {
|
2026-05-08 01:16:20 +02:00
|
|
|
const props = feature.properties || {};
|
|
|
|
|
const featId = props._feature_id || feature.id;
|
2026-05-07 23:31:33 +02:00
|
|
|
let content = `<b>${props.name || 'Elemento'}</b><br>
|
|
|
|
|
Progreso: ${props.progress || 'N/A'}%<br>
|
|
|
|
|
Responsable: ${props.responsible || '-'}<br>
|
2026-05-08 01:16:20 +02:00
|
|
|
<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>`;
|
2026-05-07 23:31:33 +02:00
|
|
|
layer.bindPopup(content);
|
|
|
|
|
layer.on('click', function(e) {
|
2026-05-08 01:16:20 +02:00
|
|
|
@this.selectFeature(featId);
|
2026-05-07 23:31:33 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (@json(in_array($phase->id, $activeLayers))) {
|
|
|
|
|
layers[{{ $phase->id }}].addTo(map);
|
|
|
|
|
}
|
2026-05-08 01:16:20 +02:00
|
|
|
|
2026-05-07 23:31:33 +02:00
|
|
|
|
|
|
|
|
// 🔁 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
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 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();
|
|
|
|
|
map.setView(latlng, 16);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
alert('Geolocalización no soportada');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 01:16:20 +02:00
|
|
|
window.updateFeatureProgress = function(featureId, progress) {
|
|
|
|
|
@this.updateProgress(featureId, progress, 'Actualizado desde mapa');
|
2026-05-07 23:31:33 +02:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
@endpush
|