Initial commit - construprogress app

This commit is contained in:
2026-05-07 23:31:33 +02:00
commit 156aa14bbb
157 changed files with 21654 additions and 0 deletions
View File
@@ -0,0 +1,252 @@
<div class="flex flex-col h-screen">
{{-- Cabecera fija --}}
<div class="flex justify-between items-center mb-4 px-4 pt-4 flex-shrink-0">
<h1 class="text-2xl font-bold">Gestión de elementos - {{ $phase->name }}</h1>
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm"> Volver al mapa</a>
</div>
<div class="flex-1 overflow-hidden px-4 pb-4">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2 h-full">
{{-- Columna izquierda --}}
<div class="space-y-4 overflow-y-auto h-full pr-2">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Importar archivo</h2>
<form wire:submit.prevent="importFile">
<div class="form-control">
<label class="label">Nombre de capa</label>
<input type="text" wire:model="layerName" class="input input-bordered" required>
</div>
<div class="form-control">
<label class="label">Color</label>
<input type="color" wire:model="layerColor" class="input input-bordered">
</div>
<div class="form-control">
<label class="label">Archivo (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
</div>
<button type="submit" class="btn btn-primary w-full mt-2">Subir y convertir</button>
</form>
<div class="divider"></div>
<button wire:click="createEmptyLayer" class="btn btn-secondary w-full">Crear capa vacía</button>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Capas existentes</h2>
<div class="space-y-2">
@foreach($layers as $layer)
<div class="flex justify-between items-center p-2 border rounded">
<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded-full" style="background: {{ $layer->geojson_data['style']['color'] ?? '#ccc' }}"></span>
{{ $layer->name }}
</div>
<div>
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info"><x-heroicon-s-trash />Editar</button>
<button wire:click="deleteLayer({{ $layer->id }})" class="btn btn-xs btn-error"><x-heroicon-s-trash />Eliminar</button>
</div>
</div>
@endforeach
@if($layers->isEmpty())
<p class="text-center">Sin capas. Crea una o importa.</p>
@endif
</div>
</div>
</div>
</div>
{{-- Columna derecha: mapa PERSISTENTE --}}
<div class="lg:col-span-2 flex flex-col h-full">
<div class="card bg-base-100 shadow-xl flex-1 flex flex-col">
<div class="card-body flex-1 flex flex-col p-2">
<h2 class="card-title">Editor gráfico</h2>
@if($selectedLayer)
<div class="mt-3 flex gap-2">
<button id="saveDrawingBtn" class="btn btn-primary">💾 Guardar cambios</button>
<button wire:click="cancelEditing" class="btn btn-outline">Cancelar edición</button>
</div>
@endif
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div>
</div>
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
// Variables globales
let mapInitialized = false;
let mapInstance = null;
let drawnItems = null; // Grupo que contiene todos los elementos editables
let currentColor = '#3b82f6';
function initializeMap() {
if (mapInitialized) return;
const container = document.getElementById('permanentMap');
if (!container) {
setTimeout(initializeMap, 200);
return;
}
const center = [{{ $project->lat }}, {{ $project->lng }}];
mapInstance = L.map('permanentMap').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(mapInstance);
// Grupo que contendrá todos los elementos (sin anidar)
drawnItems = L.featureGroup().addTo(mapInstance);
const drawControl = new L.Control.Draw({
edit: { featureGroup: drawnItems },
draw: {
polygon: true,
polyline: true,
marker: true,
circle: false,
rectangle: false,
circlemarker: false
}
});
mapInstance.addControl(drawControl);
// Cuando se dibuja un nuevo elemento, lo añadimos al grupo
mapInstance.on(L.Draw.Event.CREATED, (e) => {
const layer = e.layer;
// Asignar propiedades por defecto (necesario para guardar)
if (!layer.feature) {
layer.feature = {
type: 'Feature',
properties: { name: 'Nuevo elemento', progress: 0, responsible: '' }
};
}
drawnItems.addLayer(layer);
});
mapInitialized = true;
console.log('Mapa inicializado correctamente');
}
// Limpia todas las capas actuales
function clearAllLayers() {
if (drawnItems) {
drawnItems.clearLayers();
}
}
// Cargar una capa GeoJSON haciendo cada feature editable individualmente
function loadGeoJSONLayer(geojson, color) {
console.log('Cargando capa GeoJSON:', geojson);
if (!mapInstance) return;
clearAllLayers();
currentColor = color;
if (!geojson || !geojson.features || geojson.features.length === 0) {
console.warn('GeoJSON sin features');
return;
}
geojson.features.forEach(feature => {
// Crear capa GeoJSON para este feature individual
const tempLayer = L.geoJSON(feature, {
style: { color: color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: (f, l) => {
l.feature = f; // guardar propiedades originales
const props = f.properties;
const content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 0}%<br>
Responsable: ${props.responsible || '-'}`;
l.bindPopup(content);
}
});
// L.geoJSON devuelve un FeatureGroup, extraemos cada capa hija
tempLayer.eachLayer(subLayer => {
subLayer.feature = feature;
drawnItems.addLayer(subLayer);
});
});
// Ajustar zoom a los elementos
if (drawnItems.getLayers().length > 0) {
const bounds = drawnItems.getBounds();
if (bounds.isValid()) {
mapInstance.fitBounds(bounds);
}
}
}
// Guardar todos los elementos (modificados y nuevos)
function saveAllLayers() {
if (!drawnItems) return;
const features = [];
drawnItems.eachLayer(layer => {
const feature = extractFeatureFromLayer(layer);
if (feature) features.push(feature);
});
if (features.length === 0) {
alert('No hay elementos para guardar.');
return;
}
const finalGeojson = {
type: 'FeatureCollection',
features: features,
style: { color: document.querySelector('input[type="color"]')?.value || '#3b82f6' }
};
console.log('Guardando GeoJSON:', finalGeojson);
@this.saveManualGeojson(JSON.stringify(finalGeojson));
}
// Extraer geometría y propiedades de una capa Leaflet
function extractFeatureFromLayer(layer) {
let geojson = layer.toGeoJSON();
if (geojson && geojson.geometry) {
// Si la capa tiene propiedades asociadas, las conservamos
if (!geojson.properties && layer.feature?.properties) {
geojson.properties = layer.feature.properties;
} else if (!geojson.properties) {
geojson.properties = { name: 'Elemento', progress: 0, responsible: '' };
}
return geojson;
}
return null;
}
function setupSaveButton() {
const btn = document.getElementById('saveDrawingBtn');
if (btn && !btn.hasAttribute('data-listener')) {
btn.setAttribute('data-listener', 'true');
btn.addEventListener('click', saveAllLayers);
}
}
// Inicialización con Livewire
document.addEventListener('livewire:init', () => {
console.log('Livewire inicializado');
initializeMap();
setupSaveButton();
// Escuchar selección de capa (Livewire v3)
Livewire.on('layerSelectedForEdit', (data) => {
const payload = Array.isArray(data) ? data[0] : data;
console.log('Evento layerSelectedForEdit recibido:', payload);
if (!mapInitialized) {
setTimeout(() => Livewire.dispatch('layerSelectedForEdit', payload), 200);
return;
}
if (payload && payload.geojson) {
loadGeoJSONLayer(payload.geojson, payload.color);
} else {
clearAllLayers();
}
});
});
// Para navegación SPA
document.addEventListener('livewire:navigated', () => {
if (!mapInitialized) initializeMap();
setupSaveButton();
});
</script>
@endpush
@@ -0,0 +1,259 @@
<div class="flex flex-col h-screen">
{{-- Cabecera fija --}}
<div class="flex justify-between items-center mb-4 px-4 pt-4 flex-shrink-0">
<h1 class="text-2xl font-bold">Gestión de elementos - {{ $phase->name }}</h1>
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm"> Volver al mapa</a>
</div>
<div class="flex-1 overflow-hidden px-4 pb-4">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-2 h-full">
{{-- Columna izquierda --}}
<div class="space-y-4 overflow-y-auto h-full pr-2">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Importar archivo</h2>
<form wire:submit.prevent="importFile">
<div class="form-control">
<label class="label">Nombre de capa</label>
<input type="text" wire:model="layerName" class="input input-bordered" required>
</div>
<div class="form-control">
<label class="label">Color</label>
<input type="color" wire:model="layerColor" class="input input-bordered">
</div>
<div class="form-control">
<label class="label">Archivo (GeoJSON, KML, Shapefile.zip, DWG)</label></br>
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered">
@error('uploadFile') <span class="text-error">{{ $message }}</span> @enderror
</div>
<button type="submit" class="btn btn-primary w-full mt-2">Subir y convertir</button>
</form>
<div class="divider"></div>
<button wire:click="createEmptyLayer" class="btn btn-secondary w-full">Crear capa vacía</button>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Capas existentes</h2>
<div class="space-y-2 max-h-96 overflow-y-auto">
@foreach($layers as $layer)
<div wire:key="layer-{{ $layer->id }}" class="flex justify-between items-center p-2 border rounded">
<div class="flex items-center gap-2">
<input type="checkbox"
wire:change="toggleLayerVisibility({{ $layer->id }})"
@if(in_array($layer->id, $visibleLayers)) checked @endif
class="checkbox checkbox-sm" />
<span class="w-4 h-4 rounded-full" style="background: {{ $layer->geojson_data['style']['color'] ?? '#ccc' }}"></span>
<span class="{{ $selectedLayer && $selectedLayer->id == $layer->id ? 'font-bold text-primary' : '' }}">
{{ $layer->name }}
</span>
</div>
<div>
<button wire:click="selectLayer({{ $layer->id }})" class="btn btn-xs btn-info">✏️ Editar</button>
<button wire:click="deleteLayer({{ $layer->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar capa?')">🗑️</button>
</div>
</div>
@endforeach
@if($layers->isEmpty())
<p class="text-center">Sin capas. Crea una o importa.</p>
@endif
</div>
</div>
</div>
</div>
{{-- Columna derecha: mapa PERSISTENTE --}}
<div class="lg:col-span-2 flex flex-col h-full">
<div class="card bg-base-100 shadow-xl flex-1 flex flex-col">
<div class="card-body flex-1 flex flex-col p-2">
<h2 class="card-title">Editor gráfico</h2>
@if($selectedLayer)
<div class="mt-3 flex gap-2">
<button id="saveDrawingBtn" class="btn btn-primary">💾 Guardar cambios</button>
<button wire:click="cancelEditing" class="btn btn-outline">Cancelar edición</button>
</div>
@endif
<div id="permanentMap" style="flex: 1; min-height: 500px; width: 100%; background: #e2e8f0;" wire:ignore></div>
</div>
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
let map, displayGroup, editableGroup;
let currentEditableLayerId = null;
let allLayersData = {}; // id -> {geojson, color}
let visibleLayerIds = [];
// Inicialización del mapa
function initMap() {
if (map) return;
const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('permanentMap').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);
displayGroup = L.layerGroup().addTo(map); // capas de solo lectura
editableGroup = L.featureGroup().addTo(map); // capa editable (drawnItems)
// Control de dibujo (solo para editableGroup)
const drawControl = new L.Control.Draw({
edit: { featureGroup: editableGroup },
draw: {
polygon: true,
polyline: true,
marker: true,
circle: false,
rectangle: false,
circlemarker: false
}
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, (e) => {
const layer = e.layer;
if (!layer.feature) {
layer.feature = {
type: 'Feature',
properties: { name: 'Nuevo elemento', progress: 0, responsible: '' }
};
}
editableGroup.addLayer(layer);
});
}
// Renderiza todas las capas de solo lectura según visibleLayers (excluyendo la editable actual)
function renderDisplayLayers() {
displayGroup.clearLayers();
for (let id of visibleLayerIds) {
if (id == currentEditableLayerId) continue; // la editable ya se muestra en editableGroup
const data = allLayersData[id];
if (!data) continue;
L.geoJSON(data.geojson, {
style: { color: data.color, weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: (feature, layer) => {
const props = feature.properties;
const content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 0}%<br>
Responsable: ${props.responsible || '-'}`;
layer.bindPopup(content);
}
}).addTo(displayGroup);
}
}
// Carga la capa editable (limpia editableGroup y añade los features de la capa seleccionada)
function loadEditableLayer(layerId, geojson, color) {
editableGroup.clearLayers();
currentEditableLayerId = layerId;
if (!geojson || !geojson.features) return;
geojson.features.forEach(feature => {
const tempLayer = L.geoJSON(feature, {
style: { color: color, weight: 4, opacity: 1, fillOpacity: 0.4 },
onEachFeature: (f, l) => {
l.feature = f;
const props = f.properties;
const content = `<b>${props.name || 'Elemento'}</b><br>
Progreso: ${props.progress || 0}%<br>
Responsable: ${props.responsible || '-'}<br>
<em>Editable</em>`;
l.bindPopup(content);
}
});
tempLayer.eachLayer(subLayer => {
subLayer.feature = feature;
editableGroup.addLayer(subLayer);
});
});
}
// Guardar todos los elementos editables (actualiza la capa seleccionada)
function saveCurrentEditableLayer() {
if (!currentEditableLayerId) {
alert('No hay capa seleccionada para editar.');
return;
}
const features = [];
editableGroup.eachLayer(layer => {
let geojson = layer.toGeoJSON();
if (geojson && geojson.geometry) {
if (!geojson.properties && layer.feature?.properties) {
geojson.properties = layer.feature.properties;
} else if (!geojson.properties) {
geojson.properties = { name: 'Elemento', progress: 0, responsible: '' };
}
features.push(geojson);
}
});
if (features.length === 0) {
alert('No hay elementos en la capa editable.');
return;
}
const finalGeojson = {
type: 'FeatureCollection',
features: features,
style: { color: allLayersData[currentEditableLayerId]?.color || '#3b82f6' }
};
@this.saveManualGeojson(JSON.stringify(finalGeojson));
}
// Eventos Livewire
document.addEventListener('livewire:init', () => {
initMap();
// Botón guardar (se conecta dinámicamente)
const saveBtn = document.getElementById('saveDrawingBtn');
if (saveBtn) saveBtn.addEventListener('click', saveCurrentEditableLayer);
// Datos iniciales
Livewire.on('initialLayersData', (data) => {
const payload = Array.isArray(data) ? data[0] : data;
allLayersData = {};
payload.layers.forEach(layer => {
allLayersData[layer.id] = {
geojson: layer.geojson,
color: layer.color
};
});
visibleLayerIds = payload.visibleLayers;
renderDisplayLayers();
if (payload.selectedLayerId && allLayersData[payload.selectedLayerId]) {
const sel = allLayersData[payload.selectedLayerId];
loadEditableLayer(payload.selectedLayerId, sel.geojson, sel.color);
} else {
currentEditableLayerId = null;
editableGroup.clearLayers();
}
});
// Cambio de visibilidad
Livewire.on('visibilityChanged', (visibleIds) => {
visibleLayerIds = Array.isArray(visibleIds) ? visibleIds : visibleIds[0];
renderDisplayLayers();
});
// Selección de capa para editar
Livewire.on('layerSelectedForEdit', (data) => {
const payload = Array.isArray(data) ? data[0] : data;
if (!payload) {
currentEditableLayerId = null;
editableGroup.clearLayers();
renderDisplayLayers();
return;
}
if (!allLayersData[payload.layerId]) return;
loadEditableLayer(payload.layerId, payload.geojson, payload.color);
// Asegurar que la capa es visible
if (!visibleLayerIds.includes(payload.layerId)) {
visibleLayerIds.push(payload.layerId);
renderDisplayLayers();
}
});
});
</script>
@endpush
@@ -0,0 +1,3 @@
<div>
{{-- The best athlete wants his opponent at his best. --}}
</div>
@@ -0,0 +1,118 @@
<?php
use App\Livewire\Actions\Logout;
use Livewire\Volt\Component;
use App\Models\Project;
new class extends Component
{
/**
* Log the current user out of the application.
*/
public function logout(Logout $logout): void
{
$logout();
$this->redirect('/', navigate: true);
}
}; ?>
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}" wire:navigate>
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
</a>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</x-nav-link>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('projects.index')" :active="request()->routeIs('projects.index')" wire:navigate>
{{ __('Proyectos') }}
</x-nav-link>
</div>
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div x-data="{{ json_encode(['name' => auth()->user()->name]) }}" x-text="name" x-on:profile-updated.window="name = $event.detail.name"></div>
<div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('profile')" wire:navigate>
{{ __('Profile') }}
</x-dropdown-link>
<!-- Authentication -->
<button wire:click="logout" class="w-full text-start">
<x-dropdown-link>
{{ __('Log Out') }}
</x-dropdown-link>
</button>
</x-slot>
</x-dropdown>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</x-responsive-nav-link>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800" x-data="{{ json_encode(['name' => auth()->user()->name]) }}" x-text="name" x-on:profile-updated.window="name = $event.detail.name"></div>
<div class="font-medium text-sm text-gray-500">{{ auth()->user()->email }}</div>
</div>
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile')" wire:navigate>
{{ __('Profile') }}
</x-responsive-nav-link>
<!-- Authentication -->
<button wire:click="logout" class="w-full text-start">
<x-responsive-nav-link>
{{ __('Log Out') }}
</x-responsive-nav-link>
</button>
</div>
</div>
</div>
</nav>
@@ -0,0 +1,62 @@
<?php
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.guest')] class extends Component
{
public string $password = '';
/**
* Confirm the current user's password.
*/
public function confirmPassword(): void
{
$this->validate([
'password' => ['required', 'string'],
]);
if (! Auth::guard('web')->validate([
'email' => Auth::user()->email,
'password' => $this->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
session(['auth.password_confirmed_at' => time()]);
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div>
<div class="mb-4 text-sm text-gray-600">
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
</div>
<form wire:submit="confirmPassword">
<!-- Password -->
<div>
<x-input-label for="password" :value="__('Password')" />
<x-text-input wire:model="password"
id="password"
class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
{{ __('Confirm') }}
</x-primary-button>
</div>
</form>
</div>
@@ -0,0 +1,61 @@
<?php
use Illuminate\Support\Facades\Password;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.guest')] class extends Component
{
public string $email = '';
/**
* Send a password reset link to the provided email address.
*/
public function sendPasswordResetLink(): void
{
$this->validate([
'email' => ['required', 'string', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$this->only('email')
);
if ($status != Password::RESET_LINK_SENT) {
$this->addError('email', __($status));
return;
}
$this->reset('email');
session()->flash('status', __($status));
}
}; ?>
<div>
<div class="mb-4 text-sm text-gray-600">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
</div>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form wire:submit="sendPasswordResetLink">
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Email Password Reset Link') }}
</x-primary-button>
</div>
</form>
</div>
@@ -0,0 +1,71 @@
<?php
use App\Livewire\Forms\LoginForm;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.guest')] class extends Component
{
public LoginForm $form;
/**
* Handle an incoming authentication request.
*/
public function login(): void
{
$this->validate();
$this->form->authenticate();
Session::regenerate();
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div>
<!-- Session Status -->
<x-auth-session-status class="mb-4" :status="session('status')" />
<form wire:submit="login">
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="form.email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('form.email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input wire:model="form.password" id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input-error :messages="$errors->get('form.password')" class="mt-2" />
</div>
<!-- Remember Me -->
<div class="block mt-4">
<label for="remember" class="inline-flex items-center">
<input wire:model="form.remember" id="remember" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500" name="remember">
<span class="ms-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
</label>
</div>
<div class="flex items-center justify-end mt-4">
@if (Route::has('password.request'))
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
@endif
<x-primary-button class="ms-3">
{{ __('Log in') }}
</x-primary-button>
</div>
</form>
</div>
@@ -0,0 +1,88 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.guest')] class extends Component
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Handle an incoming registration request.
*/
public function register(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
$validated['password'] = Hash::make($validated['password']);
event(new Registered($user = User::create($validated)));
Auth::login($user);
$this->redirect(route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div>
<form wire:submit="register">
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input wire:model="name" id="name" class="block mt-1 w-full" type="text" name="name" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input wire:model="password" id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input wire:model="password_confirmation" id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}" wire:navigate>
{{ __('Already registered?') }}
</a>
<x-primary-button class="ms-4">
{{ __('Register') }}
</x-primary-button>
</div>
</form>
</div>
@@ -0,0 +1,105 @@
<?php
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
new #[Layout('layouts.guest')] class extends Component
{
#[Locked]
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Mount the component.
*/
public function mount(string $token): void
{
$this->token = $token;
$this->email = request()->string('email');
}
/**
* Reset the password for the given user.
*/
public function resetPassword(): void
{
$this->validate([
'token' => ['required'],
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$this->only('email', 'password', 'password_confirmation', 'token'),
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status != Password::PASSWORD_RESET) {
$this->addError('email', __($status));
return;
}
Session::flash('status', __($status));
$this->redirectRoute('login', navigate: true);
}
}; ?>
<div>
<form wire:submit="resetPassword">
<!-- Email Address -->
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input wire:model="password" id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input wire:model="password_confirmation" id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
{{ __('Reset Password') }}
</x-primary-button>
</div>
</form>
</div>
@@ -0,0 +1,58 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.guest')] class extends Component
{
/**
* Send an email verification notification to the user.
*/
public function sendVerification(): void
{
if (Auth::user()->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
return;
}
Auth::user()->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
/**
* Log the current user out of the application.
*/
public function logout(Logout $logout): void
{
$logout();
$this->redirect('/', navigate: true);
}
}; ?>
<div>
<div class="mb-4 text-sm text-gray-600">
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
</div>
@if (session('status') == 'verification-link-sent')
<div class="mb-4 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
@endif
<div class="mt-4 flex items-center justify-between">
<x-primary-button wire:click="sendVerification">
{{ __('Resend Verification Email') }}
</x-primary-button>
<button wire:click="logout" type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Log Out') }}
</button>
</div>
</div>
@@ -0,0 +1,29 @@
<div>
@if(session()->has('message'))
<div class="alert alert-success mb-2">{{ session('message') }}</div>
@endif
<table class="table table-sm">
<thead>
<tr><th>Nombre</th><th>Progreso</th><th>Color</th><th>Acciones</th></tr>
</thead>
<tbody>
@foreach($phases as $phase)
<tr>
<td>{{ $phase->name }}</td>
<td>
<div class="w-32 bg-gray-200 rounded-full h-2">
<div class="bg-primary h-2 rounded-full" style="width: {{ $phase->progress_percent }}%"></div>
</div>
{{ $phase->progress_percent }}%
</td>
<td><div class="w-6 h-6 rounded" style="background: {{ $phase->color }}"></div></td>
<td>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-info">Actualizar</a>
<button wire:click="deletePhase({{ $phase->id }})" class="btn btn-xs btn-error">Eliminar</button>
</td>
</tr>
@endforeach
</tbody>
</table>
<button wire:click="addPhase" class="btn btn-sm btn-secondary mt-2">+ Agregar Fase</button>
</div>
@@ -0,0 +1,3 @@
<div>
{{-- If you look to others for fulfillment, you will never truly be fulfilled. --}}
</div>
@@ -0,0 +1,79 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Volt\Component;
new class extends Component
{
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => ['required', 'string', 'current_password'],
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}; ?>
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Delete Account') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
</p>
</header>
<x-danger-button
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
>{{ __('Delete Account') }}</x-danger-button>
<x-modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable>
<form wire:submit="deleteUser" class="p-6">
<h2 class="text-lg font-medium text-gray-900">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<div class="mt-6">
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
<x-text-input
wire:model="password"
id="password"
name="password"
type="password"
class="mt-1 block w-3/4"
placeholder="{{ __('Password') }}"
/>
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-danger-button class="ms-3">
{{ __('Delete Account') }}
</x-danger-button>
</div>
</form>
</x-modal>
</section>
@@ -0,0 +1,79 @@
<?php
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Volt\Component;
new class extends Component
{
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => ['required', 'string', 'current_password'],
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => Hash::make($validated['password']),
]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->dispatch('password-updated');
}
}; ?>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Update Password') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Ensure your account is using a long, random password to stay secure.') }}
</p>
</header>
<form wire:submit="updatePassword" class="mt-6 space-y-6">
<div>
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
<x-text-input wire:model="current_password" id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
<x-input-error :messages="$errors->get('current_password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password" :value="__('New Password')" />
<x-text-input wire:model="password" id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
<x-text-input wire:model="password_confirmation" id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
<x-action-message class="me-3" on="password-updated">
{{ __('Saved.') }}
</x-action-message>
</div>
</form>
</section>
@@ -0,0 +1,115 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rule;
use Livewire\Volt\Component;
new class extends Component
{
public string $name = '';
public string $email = '';
/**
* Mount the component.
*/
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
/**
* Update the profile information for the currently authenticated user.
*/
public function updateProfileInformation(): void
{
$user = Auth::user();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($user->id)],
]);
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
$this->dispatch('profile-updated', name: $user->name);
}
/**
* Send an email verification notification to the current user.
*/
public function sendVerification(): void
{
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false));
return;
}
$user->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
}; ?>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Profile Information') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __("Update your account's profile information and email address.") }}
</p>
</header>
<form wire:submit="updateProfileInformation" class="mt-6 space-y-6">
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input wire:model="name" id="name" name="name" type="text" class="mt-1 block w-full" required autofocus autocomplete="name" />
<x-input-error class="mt-2" :messages="$errors->get('name')" />
</div>
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input wire:model="email" id="email" name="email" type="email" class="mt-1 block w-full" required autocomplete="username" />
<x-input-error class="mt-2" :messages="$errors->get('email')" />
@if (auth()->user() instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! auth()->user()->hasVerifiedEmail())
<div>
<p class="text-sm mt-2 text-gray-800">
{{ __('Your email address is unverified.') }}
<button wire:click.prevent="sendVerification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@endif
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
<x-action-message class="me-3" on="profile-updated">
{{ __('Saved.') }}
</x-action-message>
</div>
</form>
</section>
@@ -0,0 +1,3 @@
<div>
{{-- In work, do what you enjoy. --}}
</div>
@@ -0,0 +1,44 @@
<div>
<div class="flex justify-between mb-4">
<input type="text" wire:model.live="search" placeholder="Buscar proyecto..." class="input input-bordered w-64" />
<select wire:model.live="statusFilter" class="select select-bordered">
<option value="">Todos</option>
<option value="planning">Planificación</option>
<option value="in_progress">En obra</option>
<option value="paused">Pausado</option>
<option value="completed">Finalizado</option>
</select>
@can('create projects')
<a href="{{ route('projects.create') }}" class="btn btn-primary">+ Nuevo Proyecto</a>
@endcan
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr><th>Nombre</th><th>Dirección</th><th>Estado</th><th>Progreso global</th><th>Acciones</th></tr>
</thead>
<tbody>
@foreach($projects as $project)
<tr>
<td>{{ $project->name }}</td>
<td>{{ $project->address }}</td>
<td>{{ ucfirst($project->status) }}</td>
<td>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="bg-primary h-2.5 rounded-full" style="width: {{ $project->phases->avg('progress_percent') }}%"></div>
</div>
</td>
<td>
<a href="{{ route('projects.map', $project) }}" class="btn btn-sm btn-outline">Ver Mapa</a>
@can('edit projects')
<a href="{{ route('projects.edit', $project) }}" class="btn btn-sm btn-warning">Editar</a>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $projects->links() }}
</div>
@@ -0,0 +1,249 @@
<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">
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
📍 Centrar en proyecto
</button>
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full mt-2">
🧭 Mi ubicación
</button>
<a href="{{ route('layers.manage', ['project' => $project->id, 'phase' => $phase->id]) }}" class="btn btn-sm btn-info w-full mt-2">
✏️ Gestión de capas y elementos
</a>
</div>
</div>
</div>
<!-- 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: '&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
@foreach($phases as $phase)
const phase{{ $phase->id }}Data = @json($phase->currentLayer?->geojson_data);
if (phase{{ $phase->id }}Data) {
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;
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-${feature.id}" class="range range-sm" />
<button class="btn btn-xs btn-primary mt-1" onclick="updatePhaseProgress({{ $phase->id }}, document.getElementById('slider-${feature.id}').value)">Actualizar</button>
<button class="btn btn-xs btn-secondary mt-1" onclick="addNote({{ $phase->id }})">Añadir nota</button>`;
layer.bindPopup(content);
layer.on('click', function(e) {
@this.selectFeature(feature.properties.id, feature.properties);
});
}
});
if (@json(in_array($phase->id, $activeLayers))) {
layers[{{ $phase->id }}].addTo(map);
}
}
@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
// 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');
}
}
window.updatePhaseProgress = function(phaseId, progress) {
@this.updateProgress(phaseId, progress);
}
</script>
@endpush
@@ -0,0 +1,136 @@
<div>
<div class="bg-base-100 p-4 rounded shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">📋 Templates de inspección</h2>
</div>
@if(session()->has('message'))
<div class="alert alert-success mb-4">{{ session('message') }}</div>
@endif
{{-- Formulario de creación/edición con diseño de dos columnas --}}
@if($showForm)
<form wire:submit.prevent="saveTemplate" class="border p-4 rounded mb-6 bg-base-200">
<table class="w-full mb-8">
<tbody>
{{-- Nombre del template --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Nombre del template')}}
</td>
<td class="py-3">
<input type="text" wire:model="form.name"
class="input w-full"
required>
</td>
</tr>
{{-- Descripción --}}
<tr>
<td class="w-1/4 py-3 pr-4 align-top">
{{__('Descripción')}}
</td>
<td class="py-3">
<textarea wire:model="form.description" class="textarea textarea-bordered w-full" rows="2"></textarea>
</td>
</tr>
</tbody>
</table>
{{-- Campos dinámicos --}}
<div class="border-t pt-4 mt-2">
<h3 class="font-bold mb-3">Campos del formulario</h3>
@foreach($form['fields'] as $index => $field)
<div class="border p-3 rounded mb-3 bg-base-100">
{{-- Fila: nombre interno --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Nombre interno</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.name" placeholder="ej: altura_medida" class="input input-sm w-full"></div>
</div>
{{-- Fila: etiqueta --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Etiqueta visible</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.label" placeholder="ej: Altura medida (m)" class="input input-sm w-full"></div>
</div>
{{-- Fila: tipo --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Tipo de campo</div>
<div>
<select wire:model="form.fields.{{ $index }}.type" class="select select-sm w-full">
@foreach($fieldTypes as $typeValue => $typeLabel)
<option value="{{ $typeValue }}">{{ $typeLabel }}</option>
@endforeach
</select>
</div>
</div>
{{-- Fila: requerido y botón eliminar --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Requerido</div>
<div class="flex justify-between items-center">
<input type="checkbox" wire:model="form.fields.{{ $index }}.required" class="checkbox checkbox-sm">
<button type="button" wire:click="removeField({{ $index }})" class="btn btn-xs btn-error">Eliminar campo</button>
</div>
</div>
{{-- Campos adicionales según tipo --}}
@if(in_array($field['type'], ['integer', 'decimal', 'percentage']))
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Mínimo / Máximo / Paso</div>
<div class="flex gap-2">
<input type="number" wire:model="form.fields.{{ $index }}.min" placeholder="Mín" class="input input-xs w-20">
<input type="number" wire:model="form.fields.{{ $index }}.max" placeholder="Máx" class="input input-xs w-20">
<input type="number" step="any" wire:model="form.fields.{{ $index }}.step" placeholder="Paso" class="input input-xs w-20">
</div>
</div>
@elseif($field['type'] === 'select')
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div class="font-medium">Opciones (separadas por coma)</div>
<div><input type="text" wire:model="form.fields.{{ $index }}.options" placeholder="ej: Bueno,Regular,Malo" class="input input-sm w-full"></div>
</div>
@endif
</div>
@endforeach
<button type="button" wire:click="addField" class="btn btn-sm btn-secondary mt-2">+ Agregar campo</button>
</div>
<div class="flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">Guardar template</button>
<button type="button" wire:click="cancelForm" class="btn">Cancelar</button>
</div>
</form>
@endif
{{-- Tabla de templates existentes --}}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</th>
<th>Campos</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@forelse($templates as $template)
<tr>
<td>{{ $template->name }}</td>
<td>{{ $template->description ?? '-' }}</td>
<td>{{ count($template->fields) }}</td>
<td>
<button wire:click="editTemplate({{ $template->id }})" class="btn btn-xs btn-warning">
Editar
</button>
<button wire:click="deleteTemplate({{ $template->id }})" class="btn btn-xs btn-error" onclick="return confirm('¿Eliminar template?')">Eliminar</button>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center">No hay templates creados. Presiona "Nuevo template" para comenzar.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,26 @@
<nav class="-mx-3 flex flex-1 justify-end">
@auth
<a
href="{{ url('/dashboard') }}"
class="rounded-md px-3 py-2 text-black ring-1 ring-transparent transition hover:text-black/70 focus:outline-none focus-visible:ring-[#FF2D20] dark:text-white dark:hover:text-white/80 dark:focus-visible:ring-white"
>
Dashboard
</a>
@else
<a
href="{{ route('login') }}"
class="rounded-md px-3 py-2 text-black ring-1 ring-transparent transition hover:text-black/70 focus:outline-none focus-visible:ring-[#FF2D20] dark:text-white dark:hover:text-white/80 dark:focus-visible:ring-white"
>
Log in
</a>
@if (Route::has('register'))
<a
href="{{ route('register') }}"
class="rounded-md px-3 py-2 text-black ring-1 ring-transparent transition hover:text-black/70 focus:outline-none focus-visible:ring-[#FF2D20] dark:text-white dark:hover:text-white/80 dark:focus-visible:ring-white"
>
Register
</a>
@endif
@endauth
</nav>