feat: i18n, language switcher fix, DataTable improvements, blade translations

- Translation system: lang/es/ PHP files (auth, validation, pagination, passwords)
- Rappasoft vendor translations published (lang/vendor/livewire-tables/es/)
- JSON files synced to 391 keys (EN + ES, full parity)
- APP_LOCALE changed to 'es', users.locale column default changed to 'es'
- Language switcher fixed: JS event + window.location.reload() avoids /livewire/update redirect
- SetLocale middleware fallback uses config('app.locale') instead of hardcoded 'en'
- setSortingPillsEnabled(false) on ProjectTable, CompanyTable, UserTable
- Translated 17 blade views: project-map, template-manager, layer-manager,
  company-management, phase-list, media-manager, reports-dashboard,
  client-projects, layer-upload, project-form, project-map-editor-tab,
  admin/users, projects/media, projects/templates, layouts/client
- Navigation 'Empresas' link uses __('Companies')
- Fixed typo key 'Fases and layers' -> 'Phases and layers'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:05:53 +02:00
parent 052e1397df
commit 7d854ffb0a
85 changed files with 8499 additions and 1339 deletions
@@ -0,0 +1,270 @@
<div class="card bg-base-100 shadow">
<div class="card-body p-8">
<form wire:submit.prevent="save">
@if($errors->any())
<div class="alert alert-error text-sm mb-6">
<x-heroicon-o-exclamation-circle class="w-5 h-5 shrink-0" />
<ul class="list-disc pl-3 space-y-0.5">
@foreach($errors->all() as $e) <li>{{ $e }}</li> @endforeach
</ul>
</div>
@endif
{{-- ══════════════════════════════════════════════════════════════════
1. IDENTIFICACIÓN
══════════════════════════════════════════════════════════════════ --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">Identificación</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Nombre <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="text" wire:model="name"
class="input input-bordered w-full"
placeholder="Edificio Residencial Las Palmas"
autofocus />
@error('name') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Referencia
<p class="text-xs text-gray-400 font-normal mt-0.5">Código interno o expediente</p>
</label>
<div class="flex-1">
<input type="text" wire:model="reference"
class="input input-bordered w-full max-w-xs"
placeholder="OBR-2026-001" />
@error('reference') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@if($project)
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">Estado</label>
<div class="flex-1">
<select wire:model="status" class="select select-bordered w-full max-w-xs">
<option value="planning">Planificación</option>
<option value="in_progress">En progreso</option>
<option value="paused">Pausado</option>
<option value="completed">Completado</option>
</select>
@error('status') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
@endif
</div>
</div>
{{-- ══════════════════════════════════════════════════════════════════
2. UBICACIÓN
══════════════════════════════════════════════════════════════════ --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">Ubicación</h3>
{{-- Search box --}}
<div class="flex gap-2 mb-3">
<label class="input input-bordered input-sm flex items-center gap-2 flex-1">
<x-heroicon-o-magnifying-glass class="w-4 h-4 opacity-40 shrink-0" />
<input type="text" id="map-search-input" class="grow"
placeholder="Buscar dirección, ciudad, lugar…"
autocomplete="off" />
</label>
<button type="button" id="map-search-btn"
class="btn btn-outline btn-sm gap-1 shrink-0">
<x-heroicon-o-magnifying-glass class="w-4 h-4" />
Buscar
</button>
</div>
{{-- Geocode status message --}}
<p id="geocode-status" class="text-xs text-gray-400 mb-2 min-h-[1rem]"></p>
{{-- Map (wire:ignore prevents Livewire morphing from destroying Leaflet) --}}
<div wire:ignore
id="project-location-map"
data-lat="{{ $lat }}"
data-lng="{{ $lng }}"
style="height: 380px; border-radius: 0.5rem; overflow: hidden; z-index: 1;"
class="border border-base-300 shadow-sm mb-4">
</div>
<p class="text-xs text-gray-400 mb-4 flex items-center gap-1">
<x-heroicon-o-cursor-arrow-rays class="w-3.5 h-3.5 opacity-60" />
Pulsa en el mapa o arrastra el marcador para actualizar la ubicación.
</p>
<div class="space-y-4">
{{-- Lat/Lng (read-only, filled by map) --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Coordenadas
<p class="text-xs text-gray-400 font-normal mt-0.5">Auto al pulsar el mapa</p>
</label>
<div class="flex-1 flex items-center gap-3">
<div class="flex-1">
<label class="label-text text-xs mb-0.5">Latitud</label>
<input type="text" wire:model="lat" readonly
id="input-lat"
class="input input-bordered input-sm w-full bg-base-200 font-mono"
placeholder="40.41680000" />
@error('lat') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
<span class="text-gray-300 mt-5">/</span>
<div class="flex-1">
<label class="label-text text-xs mb-0.5">Longitud</label>
<input type="text" wire:model="lng" readonly
id="input-lng"
class="input input-bordered input-sm w-full bg-base-200 font-mono"
placeholder="-3.70380000" />
@error('lng') <p class="text-error text-xs mt-0.5">{{ $message }}</p> @enderror
</div>
</div>
</div>
{{-- Dirección --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Dirección <span class="text-error">*</span>
</label>
<div class="flex-1">
<textarea wire:model="address" rows="2"
class="textarea textarea-bordered w-full"
placeholder="Calle Gran Vía 28, 28013 Madrid, España"></textarea>
@error('address') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
{{-- País custom dropdown with flag images (native <select> can't render emoji on Windows) --}}
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">País</label>
<div class="flex-1 max-w-xs">
<div x-data="{ open: false, q: '' }"
@click.outside="open = false; q = ''"
class="relative">
{{-- Trigger button --}}
<button type="button"
@click="open = !open; if(open) $nextTick(() => $refs.qs?.focus())"
class="btn btn-outline w-full justify-start gap-2 font-normal h-12">
@if($country && isset($countryList[$country]))
<img src="https://flagcdn.com/w20/{{ $country }}.png"
class="w-6 h-4 object-cover rounded-sm shrink-0"
onerror="this.style.display='none'" />
<span>{{ $countryList[$country] }}</span>
@else
<span class="text-gray-400"> Sin especificar </span>
@endif
<x-heroicon-o-chevron-up-down class="w-4 h-4 ml-auto opacity-40 shrink-0" />
</button>
{{-- Dropdown panel --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute z-50 mt-1 w-full bg-base-100 border border-base-300 rounded-xl shadow-xl overflow-hidden"
style="display:none">
{{-- Search --}}
<div class="p-2 border-b border-base-200">
<input x-ref="qs" x-model="q" type="text"
placeholder="Buscar país…"
class="input input-sm input-bordered w-full"
@keydown.escape="open = false; q = ''" />
</div>
{{-- Clear option --}}
<button type="button"
@click="$wire.set('country', ''); open = false; q = ''"
class="flex items-center gap-2 w-full px-3 py-2 hover:bg-base-200 text-sm text-gray-400 border-b border-base-200">
Sin especificar
</button>
{{-- Country list --}}
<ul class="overflow-y-auto max-h-52 py-1">
@foreach($countryList as $code => $cName)
<li>
<button type="button"
x-show="q === '' || '{{ strtolower(addslashes($cName)) }}'.includes(q.toLowerCase())"
@click="$wire.set('country', '{{ $code }}'); open = false; q = ''"
class="flex items-center gap-2.5 w-full px-3 py-1.5 hover:bg-base-200 text-sm text-left {{ $country === $code ? 'bg-primary/10 font-semibold text-primary' : '' }}">
<img src="https://flagcdn.com/w20/{{ $code }}.png"
class="w-6 h-4 object-cover rounded-sm shrink-0"
loading="lazy"
onerror="this.style.display='none'" />
{{ $cName }}
@if($country === $code)
<x-heroicon-o-check class="w-3.5 h-3.5 ml-auto shrink-0" />
@endif
</button>
</li>
@endforeach
</ul>
</div>
</div>
@error('country') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ══════════════════════════════════════════════════════════════════
3. PLANIFICACIÓN
══════════════════════════════════════════════════════════════════ --}}
<div class="pb-6 mb-6 border-b border-base-200">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-4">Planificación</h3>
<div class="space-y-4">
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Fecha inicio <span class="text-error">*</span>
</label>
<div class="flex-1">
<input type="date" wire:model="startDate"
class="input input-bordered w-full max-w-xs" />
@error('startDate') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-start gap-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Fecha fin estimada
<p class="text-xs text-gray-400 font-normal mt-0.5">Vacío = sin fecha límite</p>
</label>
<div class="flex-1">
<input type="date" wire:model="endDateEstimated"
class="input input-bordered w-full max-w-xs" />
@error('endDateEstimated') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
</div>
{{-- ── Botones ─────────────────────────────────────────────────────── --}}
<div class="flex items-center justify-between pt-2">
<a href="{{ route('projects.index') }}" class="btn btn-outline gap-1" wire:navigate>
<x-heroicon-o-x-mark class="w-4 h-4" />
Cancelar
</a>
<button type="submit" class="btn btn-primary gap-2"
wire:loading.attr="disabled" wire:target="save">
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
<x-heroicon-o-check class="w-4 h-4" />
{{ $project ? 'Guardar cambios' : 'Crear proyecto' }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,275 @@
<div class="p-4 space-y-4">
{{-- Page header --}}
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-3">
<a href="{{ route('projects.map', $project) }}"
class="btn btn-sm btn-ghost gap-1">
<x-heroicon-o-arrow-left class="w-4 h-4" />
{{ __('Back to Map') }}
</a>
<h1 class="text-xl font-bold">{{ __('Cronograma') }}: {{ $project->name }}</h1>
</div>
<div class="flex items-center gap-3">
<a href="{{ route('projects.report', $project) }}"
target="_blank"
class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-document-text class="w-4 h-4" />
{{ __('Report PDF') }}
</a>
<span class="text-sm text-base-content/60">
{{ $project->start_date?->format('d/m/Y') ?? __('N/A') }}
&mdash;
{{ $project->end_date_estimated?->format('d/m/Y') ?? __('N/A') }}
</span>
</div>
</div>
{{-- Legend --}}
<div class="flex items-center gap-4 text-sm flex-wrap">
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded" style="background:#3b82f6"></span>
{{ __('Planificado') }}
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded" style="background:#22c55e"></span>
{{ __('Real') }}
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-5 h-3 rounded border-2" style="background:#fee2e2;border-color:#ef4444"></span>
{{ __('Retrasado') }}
</span>
</div>
{{-- Editor de fechas por fase (siempre visible) --}}
<div class="bg-base-100 rounded-box border border-base-300 p-4 mb-4">
<h3 class="font-semibold text-sm mb-3">Fechas planificadas y reales por fase</h3>
<div class="space-y-3">
@foreach($phases as $phase)
<div x-data="{
ps: '{{ $phase->planned_start?->format('Y-m-d') ?? '' }}',
pe: '{{ $phase->planned_end?->format('Y-m-d') ?? '' }}',
as_: '{{ $phase->actual_start?->format('Y-m-d') ?? '' }}',
ae: '{{ $phase->actual_end?->format('Y-m-d') ?? '' }}'
}" class="grid grid-cols-2 md:grid-cols-5 gap-2 items-center text-sm border-b pb-3 last:border-0">
<div class="font-medium truncate flex items-center gap-1">
<span class="w-2 h-2 rounded-full inline-block flex-shrink-0" style="background:{{ $phase->color ?? '#3b82f6' }}"></span>
{{ $phase->name }}
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Plan. inicio</label>
<input type="date" x-model="ps" class="input input-xs input-bordered" />
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Plan. fin</label>
<input type="date" x-model="pe" class="input input-xs input-bordered" />
</div>
<div class="form-control">
<label class="label-text text-xs text-gray-500">Real inicio</label>
<input type="date" x-model="as_" class="input input-xs input-bordered" />
</div>
<div class="flex flex-col gap-1">
<label class="label-text text-xs text-gray-500">Real fin</label>
<div class="flex gap-1">
<input type="date" x-model="ae" class="input input-xs input-bordered flex-1" />
<button @click="$wire.updatePhaseDates({{ $phase->id }}, ps, pe, as_, ae)"
class="btn btn-xs btn-primary">
</button>
</div>
</div>
</div>
@endforeach
</div>
</div>
@if(empty($ganttData))
<div class="alert alert-info">
<x-heroicon-o-information-circle class="w-5 h-5" />
<span>Define fechas planificadas arriba para ver el diagrama.</span>
</div>
@else
{{-- Gantt table --}}
<div class="bg-base-100 rounded-box border border-base-300 overflow-x-auto">
<table class="w-full text-sm" style="min-width:900px;">
<thead>
<tr class="border-b border-base-300">
{{-- Phase name column --}}
<th class="text-left px-3 py-2 font-semibold bg-base-200" style="width:200px;min-width:200px;">
{{ __('Fase') }}
</th>
{{-- Month header row --}}
<th class="px-0 py-0 bg-base-200" style="min-width:400px;">
@php
$projectStart = $project->start_date ?? now()->startOfMonth();
$projectEnd = $project->end_date_estimated ?? now()->addMonths(6);
$totalDays = max(1, $projectStart->diffInDays($projectEnd));
// Build month segments
$months = [];
$cursor = $projectStart->copy()->startOfMonth();
while ($cursor->lte($projectEnd)) {
$mStart = $cursor->copy()->max($projectStart);
$mEnd = $cursor->copy()->endOfMonth()->min($projectEnd);
$days = max(1, $mStart->diffInDays($mEnd) + 1);
$widthPct = round(($days / $totalDays) * 100, 2);
$months[] = [
'label' => $cursor->translatedFormat('M Y'),
'width_pct' => $widthPct,
];
$cursor->addMonthNoOverflow();
}
@endphp
<div class="flex w-full border-b border-base-300">
@foreach($months as $month)
<div class="text-center text-xs py-1 font-medium border-r border-base-300 last:border-r-0 truncate"
style="width:{{ $month['width_pct'] }}%;flex-shrink:0;">
{{ $month['label'] }}
</div>
@endforeach
</div>
</th>
{{-- Dates column --}}
<th class="text-left px-3 py-2 font-semibold bg-base-200 whitespace-nowrap" style="width:160px;min-width:160px;">
{{ __('Fechas') }}
</th>
{{-- Status column --}}
<th class="text-center px-3 py-2 font-semibold bg-base-200" style="width:110px;min-width:110px;">
{{ __('Estado') }}
</th>
</tr>
</thead>
<tbody>
@foreach($ganttData as $phase)
<tr class="border-b border-base-300 hover:bg-base-50 transition-colors {{ $phase['is_delayed'] ? 'bg-red-50' : '' }}">
{{-- Phase name --}}
<td class="px-3 py-3" style="width:200px;min-width:200px;vertical-align:middle;">
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-full flex-shrink-0"
style="background:{{ $phase['color'] }}"></span>
<span class="font-medium truncate" title="{{ $phase['name'] }}">
{{ $phase['name'] }}
</span>
</div>
@if($phase['features_count'] > 0)
<div class="ml-5 text-xs text-base-content/50 mt-0.5">
{{ $phase['features_count'] }} {{ __('elementos') }}
</div>
@endif
</td>
{{-- Gantt bar cell --}}
<td class="px-0 py-3" style="vertical-align:middle;">
<div class="relative w-full" style="height:36px;">
{{-- Month grid lines --}}
@php $offset = 0; @endphp
@foreach($months as $i => $month)
@if($i > 0)
<div class="absolute top-0 bottom-0 border-l border-base-300/50"
style="left:{{ $offset }}%;"></div>
@endif
@php $offset += $month['width_pct']; @endphp
@endforeach
{{-- Planned bar --}}
<div class="absolute rounded"
style="
top: 4px;
height: 13px;
left: {{ $phase['p_start_pct'] }}%;
width: {{ max(0.5, $phase['p_width_pct']) }}%;
background: {{ $phase['is_delayed'] ? '#fca5a5' : $phase['color'] }};
border: {{ $phase['is_delayed'] ? '2px solid #ef4444' : 'none' }};
opacity: 0.85;
"
title="{{ __('Planificado') }}: {{ $phase['planned_start'] }} - {{ $phase['planned_end'] }}">
</div>
{{-- Actual bar (if exists) --}}
@if($phase['a_start_pct'] !== null && $phase['a_width_pct'] !== null)
<div class="absolute rounded"
style="
top: 19px;
height: 13px;
left: {{ $phase['a_start_pct'] }}%;
width: {{ max(0.5, $phase['a_width_pct']) }}%;
background: #22c55e;
opacity: 0.85;
"
title="{{ __('Real') }}: {{ $phase['actual_start'] }} - {{ $phase['actual_end'] ?? __('En curso') }}">
</div>
@endif
{{-- Progress label --}}
<div class="absolute inset-0 flex items-center"
style="left: {{ $phase['p_start_pct'] }}%; width: {{ max(0.5, $phase['p_width_pct']) }}%;">
<span class="text-xs font-bold text-white drop-shadow px-1 truncate"
style="font-size:10px; line-height:13px; position:absolute; top:4px; left:2px;">
{{ $phase['progress'] }}%
</span>
</div>
</div>
</td>
{{-- Dates column --}}
<td class="px-3 py-3 text-xs" style="width:160px;min-width:160px;vertical-align:middle;">
<div class="space-y-0.5">
<div class="flex items-center gap-1">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" style="background:{{ $phase['color'] }}"></span>
<span class="text-base-content/70">{{ $phase['planned_start'] }} {{ $phase['planned_end'] }}</span>
</div>
@if($phase['actual_start'])
<div class="flex items-center gap-1">
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" style="background:#22c55e"></span>
<span class="text-base-content/70">
{{ $phase['actual_start'] }} {{ $phase['actual_end'] ?? __('En curso') }}
</span>
</div>
@endif
</div>
</td>
{{-- Status badge --}}
<td class="px-3 py-3 text-center" style="width:110px;min-width:110px;vertical-align:middle;">
@if($phase['is_delayed'])
<span class="badge badge-error badge-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-3 h-3" />
{{ __('En retraso') }}
</span>
@elseif($phase['progress'] >= 100)
<span class="badge badge-success badge-sm gap-1">
<x-heroicon-o-check-circle class="w-3 h-3" />
{{ __('Completado') }}
</span>
@elseif($phase['progress'] > 0)
<span class="badge badge-info badge-sm">
{{ $phase['progress'] }}%
</span>
@else
<span class="badge badge-ghost badge-sm">
{{ __('Pendiente') }}
</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Summary footer --}}
<div class="text-xs text-base-content/50 text-right">
{{ count($ganttData) }} {{ __('fases') }}
&bull;
{{ __('Actualizado') }}: {{ now()->format('d/m/Y H:i') }}
</div>
@endif
</div>
@@ -0,0 +1,396 @@
<div>
<x-slot name="header">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="{{ route('projects.list') }}" class="btn btn-ghost btn-sm px-2">
<x-heroicon-o-arrow-left class="w-4 h-4" />
</a>
<div>
<h2 class="font-bold text-xl leading-tight">{{ $project->name }}</h2>
@if($project->description)
<p class="text-sm text-gray-500 leading-tight mt-0.5">{{ Str::limit($project->description, 80) }}</p>
@endif
</div>
@php
$statusCfg = match($project->status) {
'in_progress' => ['badge-primary', 'En progreso'],
'completed' => ['badge-success', 'Completado'],
'paused' => ['badge-warning', 'Pausado'],
'planning' => ['badge-ghost', 'Planificación'],
default => ['badge-ghost', ucfirst(str_replace('_',' ',$project->status))],
};
@endphp
<span class="badge {{ $statusCfg[0] }} badge-sm">{{ $statusCfg[1] }}</span>
</div>
<div class="flex gap-2">
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-map class="w-4 h-4" />
Mapa
</a>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-calendar-days class="w-4 h-4" />
Gantt
</a>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-outline btn-sm gap-1">
<x-heroicon-o-exclamation-triangle class="w-4 h-4" />
Issues
</a>
</div>
</div>
</x-slot>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
{{-- ── KPIs fila 1 ────────────────────────────────────────────────────── --}}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{{-- Avance global --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Avance global</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ $stats['global_progress'] }}%</p>
<div class="mt-2 w-full bg-gray-200 rounded-full h-1.5">
<div class="bg-green-500 h-1.5 rounded-full transition-all" style="width: {{ $stats['global_progress'] }}%"></div>
</div>
</div>
<div class="p-3 bg-green-100 rounded-full ml-3 shrink-0">
<x-heroicon-o-chart-bar class="w-5 h-5 text-green-600" />
</div>
</div>
</div>
</div>
{{-- Fases --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Fases</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $stats['total_phases'] }}</p>
@if($stats['delayed_phases'] > 0)
<p class="text-xs text-red-500 mt-0.5">{{ $stats['delayed_phases'] }} con retraso</p>
@else
<p class="text-xs text-gray-400 mt-0.5">Sin retrasos</p>
@endif
</div>
<div class="p-3 {{ $stats['delayed_phases'] > 0 ? 'bg-red-100' : 'bg-blue-100' }} rounded-full">
<x-heroicon-o-rectangle-stack class="w-5 h-5 {{ $stats['delayed_phases'] > 0 ? 'text-red-500' : 'text-blue-600' }}" />
</div>
</div>
</div>
</div>
{{-- Elementos --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Elementos</p>
<p class="mt-1 text-3xl font-bold text-indigo-600">{{ $stats['total_features'] }}</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ $stats['completed_features'] }} completados
· {{ $stats['verified_features'] }} verificados
</p>
</div>
<div class="p-3 bg-indigo-100 rounded-full">
<x-heroicon-o-map-pin class="w-5 h-5 text-indigo-600" />
</div>
</div>
</div>
</div>
{{-- Issues --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Issues abiertos</p>
<p class="mt-1 text-3xl font-bold {{ $stats['open_issues'] > 0 ? 'text-orange-600' : 'text-gray-400' }}">
{{ $stats['open_issues'] }}
</p>
@if($stats['critical_issues'] > 0)
<p class="text-xs text-red-600 font-semibold mt-0.5">{{ $stats['critical_issues'] }} críticos</p>
@else
<p class="text-xs text-gray-400 mt-0.5">0 críticos</p>
@endif
</div>
<div class="p-3 {{ $stats['open_issues'] > 0 ? 'bg-orange-100' : 'bg-gray-100' }} rounded-full">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 {{ $stats['open_issues'] > 0 ? 'text-orange-600' : 'text-gray-400' }}" />
</div>
</div>
</div>
</div>
</div>
{{-- ── KPIs fila 2: Inspecciones ────────────────────────────────────────── --}}
<div class="grid grid-cols-3 gap-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 bg-gray-100 rounded-full shrink-0">
<x-heroicon-o-clipboard-document-list class="w-5 h-5 text-gray-600" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Total inspecciones</p>
<p class="text-2xl font-bold">{{ $stats['total_inspections'] }}</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 bg-green-100 rounded-full shrink-0">
<x-heroicon-o-check-circle class="w-5 h-5 text-green-600" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Aprobadas</p>
<p class="text-2xl font-bold text-green-600">{{ $stats['passed_inspections'] }}</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body p-4 flex-row items-center gap-4">
<div class="p-3 {{ $stats['failed_inspections'] > 0 ? 'bg-red-100' : 'bg-gray-100' }} rounded-full shrink-0">
<x-heroicon-o-x-circle class="w-5 h-5 {{ $stats['failed_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}" />
</div>
<div>
<p class="text-xs text-gray-500 uppercase tracking-wider">Rechazadas</p>
<p class="text-2xl font-bold {{ $stats['failed_inspections'] > 0 ? 'text-red-600' : 'text-gray-400' }}">
{{ $stats['failed_inspections'] }}
</p>
</div>
</div>
</div>
</div>
{{-- ── Main grid: fases + actividad reciente ───────────────────────────── --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- LEFT 2/3: Fases con progreso --}}
<div class="lg:col-span-2 space-y-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-rectangle-stack class="w-4 h-4" />
Fases del proyecto
</h3>
<a href="{{ route('projects.gantt', $project) }}" class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" />
Gantt
</a>
</div>
@if($phases->isEmpty())
<p class="text-sm text-gray-400 text-center py-6">Sin fases aún.</p>
@else
<div class="space-y-3">
@foreach($phases as $phase)
@php
$pct = round($phase->progress_percent ?? 0);
$isDelayed = $phase->planned_end && $phase->planned_end < now() && $pct < 100;
$barColor = $isDelayed ? 'bg-red-500' : ($pct >= 100 ? 'bg-green-500' : 'bg-blue-500');
$featureCount = $phase->layers->sum('features_count');
@endphp
<div class="border border-base-200 rounded-lg p-3 hover:border-primary/40 transition-colors">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">{{ $phase->name }}</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 mt-0.5">
<span>{{ $phase->layers_count }} capa(s)</span>
<span>·</span>
<span>{{ $featureCount }} elementos</span>
@if($phase->planned_start && $phase->planned_end)
<span>·</span>
<span>{{ $phase->planned_start->format('d/m/y') }} {{ $phase->planned_end->format('d/m/y') }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
@if($isDelayed)
<span class="badge badge-error badge-xs">Retraso</span>
@elseif($pct >= 100)
<span class="badge badge-success badge-xs">Completada</span>
@endif
<span class="text-sm font-bold {{ $isDelayed ? 'text-red-600' : ($pct >= 100 ? 'text-green-600' : 'text-blue-600') }}">
{{ $pct }}%
</span>
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="{{ $barColor }} h-2 rounded-full transition-all" style="width: {{ $pct }}%"></div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Empresas participantes --}}
@if($companies->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-building-office-2 class="w-4 h-4" />
Empresas participantes
</h3>
<div class="flex flex-wrap gap-3">
@foreach($companies as $company)
<div class="flex items-center gap-2 border border-base-200 rounded-lg px-3 py-2">
@if($company->logo_path && \Illuminate\Support\Facades\Storage::disk('public')->exists($company->logo_path))
<img src="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($company->logo_path) }}"
alt="" class="w-7 h-7 object-contain rounded" />
@else
<x-heroicon-o-building-office class="w-5 h-5 opacity-40" />
@endif
<div>
<p class="text-xs font-semibold leading-tight">{{ $company->apodo ?: $company->name }}</p>
@if($company->pivot->role_in_project)
<p class="text-xs text-gray-400">{{ $company->pivot->role_in_project }}</p>
@endif
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
</div>
{{-- RIGHT 1/3: Actividad reciente --}}
<div class="space-y-5">
{{-- Equipo --}}
@if($teamMembers->isNotEmpty())
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<h3 class="font-semibold text-base flex items-center gap-2 mb-3">
<x-heroicon-o-users class="w-4 h-4" />
Equipo ({{ $teamMembers->count() }})
</h3>
<div class="space-y-2">
@foreach($teamMembers->take(8) as $member)
<div class="flex items-center gap-2">
<div class="avatar placeholder shrink-0">
<div class="bg-neutral text-neutral-content rounded-full w-7">
<span class="text-xs">{{ strtoupper(substr($member->name, 0, 1)) }}</span>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ $member->name }}</p>
@if($member->pivot->role_in_project)
<p class="text-xs text-gray-400 truncate">{{ $member->pivot->role_in_project }}</p>
@endif
</div>
@foreach($member->roles->take(1) as $role)
<span class="badge badge-xs {{ $role->name === 'Admin' ? 'badge-error' : 'badge-ghost' }}">{{ $role->name }}</span>
@endforeach
</div>
@endforeach
</div>
</div>
</div>
@endif
{{-- Issues recientes --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-exclamation-triangle class="w-4 h-4 text-orange-500" />
Issues abiertos
</h3>
<a href="{{ route('projects.issues', $project) }}" class="btn btn-xs btn-outline">Ver todos</a>
</div>
@if($recentIssues->isEmpty())
<div class="text-center py-4 text-gray-400">
<x-heroicon-o-check-circle class="w-7 h-7 mx-auto mb-1 opacity-25" />
<p class="text-xs">Sin issues abiertos</p>
</div>
@else
<div class="space-y-2">
@foreach($recentIssues as $issue)
@php
$pCfg = match($issue->priority ?? 'medium') {
'critical' => 'badge-error',
'high' => 'badge-warning',
'medium' => 'badge-info',
default => 'badge-ghost',
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200">
<div class="flex items-start gap-1.5">
<span class="badge badge-xs {{ $pCfg }} shrink-0 mt-0.5">{{ ucfirst($issue->priority ?? 'medium') }}</span>
<p class="text-xs font-medium truncate">{{ $issue->title }}</p>
</div>
@if($issue->feature)
<p class="text-xs text-gray-400 mt-0.5 truncate">
<x-heroicon-o-map-pin class="w-2.5 h-2.5 inline" /> {{ $issue->feature->name }}
</p>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>
{{-- Inspecciones recientes --}}
<div class="card bg-base-100 shadow">
<div class="card-body p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-base flex items-center gap-2">
<x-heroicon-o-clipboard-document-list class="w-4 h-4 text-yellow-500" />
Inspecciones recientes
</h3>
<a href="{{ route('projects.map', $project) }}?tab=inspections" class="btn btn-xs btn-outline">Ver en mapa</a>
</div>
@if($recentInspections->isEmpty())
<div class="text-center py-4 text-gray-400">
<x-heroicon-o-clipboard-document-list class="w-7 h-7 mx-auto mb-1 opacity-25" />
<p class="text-xs">Sin inspecciones</p>
</div>
@else
<div class="space-y-2">
@foreach($recentInspections as $ins)
@php
$iCfg = match($ins->result ?? '') {
'pass' => ['badge-success', 'OK'],
'fail' => ['badge-error', 'Fallo'],
default => ['badge-ghost', 'Pendiente'],
};
@endphp
<div class="p-2.5 rounded-lg bg-base-200">
<div class="flex items-start justify-between gap-1">
<p class="text-xs font-medium truncate flex-1">
{{ $ins->template?->name ?? 'Inspección' }}
</p>
<span class="badge badge-xs {{ $iCfg[0] }} shrink-0">{{ $iCfg[1] }}</span>
</div>
<div class="flex items-center justify-between mt-0.5">
@if($ins->feature)
<p class="text-xs text-gray-400 truncate">
<x-heroicon-o-map-pin class="w-2.5 h-2.5 inline" /> {{ $ins->feature->name }}
</p>
@endif
<p class="text-xs text-gray-400 shrink-0 ml-1">{{ $ins->created_at->diffForHumans() }}</p>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
{{-- end right --}}
</div>
{{-- end main grid --}}
</div>
</div>
</div>{{-- end root --}}
@@ -10,18 +10,18 @@
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ __('Address') }}</label>
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Street address, city, etc.') }}">
<input type="text" wire:model="address" class="input input-bordered w-full" placeholder="{{ __('Address') }}">
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ __('Country') }}</label>
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('Country (auto-filled)') }}" readonly>
<label class="block text-sm font-medium mb-2">{{ __('Coordinates') }}</label>
<input type="text" wire:model="country" class="input input-bordered w-full" placeholder="{{ __('No country') }}" readonly>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('Start Date') }}</label>
<label class="block text-sm font-medium mb-2">{{ __('Start date') }}</label>
<input type="date" wire:model="start_date" class="input input-bordered w-full" required>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-2">{{ __('End Date (estimated)') }}</label>
<label class="block text-sm font-medium mb-2">{{ __('Estimated end date') }}</label>
<input type="date" wire:model="end_date_estimated" class="input input-bordered w-full">
</div>
<div class="md:col-span-2">
@@ -36,9 +36,9 @@
</div>
<div class="border rounded-lg p-4">
<h2 class="text-xl font-bold mb-4">{{ __('Project Location') }}</h2>
<h2 class="text-xl font-bold mb-4">{{ __('Location') }}</h2>
<p class="text-sm text-gray-500 mb-2">
{{ __('Click on the map to set the project location. The address and country will be filled automatically.') }}
{{ __('Click on the map or drag the marker to update the location') }}
</p>
<div id="projectMap" style="height: 400px; width: 100%; background: #e2e8f0; border-radius: 0.5rem;"></div>
<input type="hidden" wire:model="lat">
@@ -47,7 +47,7 @@
<div class="flex justify-end space-x-4">
<button type="button" wire:click="resetForm" class="btn btn-outline">
{{ __('Reset') }}
{{ __('Cancel') }}
</button>
<button type="submit" class="btn btn-primary">
{{ $projectId ? __('Update') : __('Create') }}
@@ -30,7 +30,14 @@
</div>
</td>
<td>
<a href="{{ route('projects.map', $project) }}" class="btn btn-sm btn-outline">{{ __('Map') }}</a>
<a href="{{ route('projects.dashboard', $project) }}" class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" />
Dashboard
</a>
<a href="{{ route('projects.map', $project) }}" class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-map class="w-3.5 h-3.5" />
{{ __('Map') }}
</a>
@can('edit projects')
<a href="{{ route('projects.edit', $project) }}" class="btn btn-sm btn-warning">{{ __('Edit') }}</a>
@endcan
@@ -1,9 +1,9 @@
{{-- Feature seleccionado --}}
@if($selectedFeature)
<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>
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? __('Feature') }}</h3>
<p class="text-xs text-gray-500">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
{{-- {{ __("Progress") }} --}}
@@ -17,7 +17,7 @@
<div class="form-control mb-3">
<label class="label-text">{{ __("Responsible") }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
</div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
@@ -41,9 +41,9 @@
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __("Inspection") }}</div>
<div class="form-control mb-2">
<label class="label-text">Plantilla</label>
<label class="label-text">{{ __('Template') }}</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">Seleccionar plantilla...</option>
<option value="">{{ __('Select template...') }}</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
@@ -69,7 +69,7 @@
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">Seleccionar</option>
<option value="">{{ __('Select') }}</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
@@ -97,7 +97,7 @@
<span class="font-medium">{{ $ins->template?->name ?? '{{ __("Inspection") }}' }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@@ -117,6 +117,6 @@
@else
<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>
<p>{{ __('Click on a map element or search above to edit it') }}</p>
</div>
@endif
@@ -1,4 +1,4 @@
<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
<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 flex-1 relative">
@@ -6,7 +6,7 @@
<!-- 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 and layers") }}</h3>
<h3 class="font-semibold text-base mb-2">{{ __('Phases and layers') }}</h3>
<div class="space-y-3">
@foreach($phases as $phase)
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
@@ -17,7 +17,7 @@
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>
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}</span>
</div>
{{-- Capas de esta fase --}}
@@ -27,7 +27,7 @@
<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>
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} {{ __('elem.') }}</span>
</div>
@endforeach
</div>
@@ -36,10 +36,10 @@
{{-- 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">
✏️ {{ __("Manage Layers") }}
✏️ {{ __('Manage Layers') }}
</a>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline">
📊 {{ __("Progress") }}
📊 {{ __('Progress') }}
</a>
</div>
</div>
@@ -50,7 +50,7 @@
<div class="mt-3">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" wire:change="toggleFeatureImages" @if($showFeatureImages) checked @endif class="checkbox checkbox-xs checkbox-primary" />
🖼️ {{ __("Show images on map") }}
🖼️ {{ __('Show images on map') }}
@if($featureImageMarkers)
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
@endif
@@ -60,34 +60,34 @@
{{-- Botones generales --}}
<div class="mt-2 space-y-1">
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
📁 {{ __("Project files") }}
📁 {{ __('Project files') }}
</a>
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
📍 {{ __("Centered in project") }}
📍 {{ __('Centered in project') }}
</button>
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full">
🧭 {{ __("My location") }}
🧭 {{ __('My location') }}
</button>
</div>
</div>
</div>
<!-- Columna derecha: {{ __("Edit") }} de progreso / inspecciones -->
<!-- Columna derecha: {{ __('Edit') }} 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">
<div class="flex justify-between items-center mb-2">
<h2 class="card-title">{{ __("Project Map") }}</h2>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="Pantalla completa">
<h2 class="card-title">{{ __('Map') }}</h2>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm" title="{{ __('Fullscreen') }}">
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
</button>
</div>
<!-- Tabs -->
<div class="tabs box mb-4">
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __("Edit") }}</button>
<button wire:click="setActiveTab('features')" class="tab {{ $activeTab === 'features' ? 'tab-active' : '' }}">{{ __("Features") }}</button>
<button wire:click="setActiveTab('inspections')" class="tab {{ $activeTab === 'inspections' ? 'tab-active' : '' }}">{{ __("Inspections") }}</button>
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __('Edit') }}</button>
<button wire:click="setActiveTab('features')" class="tab {{ $activeTab === 'features' ? 'tab-active' : '' }}">{{ __('Features') }}</button>
<button wire:click="setActiveTab('inspections')" class="tab {{ $activeTab === 'inspections' ? 'tab-active' : '' }}">{{ __('Inspections') }}</button>
</div>
<!-- Tab Content -->
@@ -96,14 +96,14 @@
@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>
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? __('Feature') }}</h3>
<p class="text-xs text-gray-500">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
{{-- {{ __("Progress") }} --}}
{{-- Progreso --}}
<div class="form-control mb-3">
<label class="label-text">{{ __("Progress") }}: {{ $editProgress }}%</label>
<label class="label-text">{{ __('Progress') }}: {{ $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>
@@ -111,18 +111,18 @@
</div>
<div class="form-control mb-3">
<label class="label-text">{{ __("Responsible") }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="Nombre" />
<label class="label-text">{{ __('Responsible') }}</label>
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
</div>
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3">
💾 {{ __("Save progress") }}
💾 {{ __('Save progress') }}
</button>
{{-- Gestor de archivos del feature --}}
<details class="mb-3 border rounded-lg">
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
📎 {{ __("Files of element") }}
📎 {{ __('Files of element') }}
</summary>
<div class="p-2">
@livewire('media-manager', [
@@ -134,11 +134,11 @@
{{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __("Inspection") }}</div>
<div class="divider text-xs">{{ __('Inspection') }}</div>
<div class="form-control mb-2">
<label class="label-text">Plantilla</label>
<label class="label-text">{{ __('Template') }}</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">Seleccionar plantilla...</option>
<option value="">{{ __('Select template...') }}</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
@@ -164,7 +164,7 @@
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">Seleccionar</option>
<option value="">{{ __('Select') }}</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
@@ -178,21 +178,21 @@
@endswitch
</div>
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __("Register inspection") }}</button>
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
@endif
@endif
{{-- {{ __("History") }} de inspecciones --}}
{{-- Historial de inspecciones --}}
@if($inspectionHistory->isNotEmpty())
<div class="divider text-xs">{{ __("History") }}</div>
<div class="divider text-xs">{{ __('History') }}</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="border rounded p-2 text-xs" wire:key="hist-{{ $ins->id }}">
<div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? {{ __("Inspection") }} }}</span>
<span class="font-medium">{{ $ins->template?->name ?? __('Inspection') }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@@ -203,16 +203,16 @@
<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 templates yet") }}</h3>
<div class="text-xs">{{ __("Create an inspection template") }}.</div>
<h3 class="font-bold">{{ __('No templates yet') }}</h3>
<div class="text-xs">{{ __('Create an inspection template') }}.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __("Create") }}</a>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __('Create') }}</a>
</div>
@endif
@else
<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>
<p>{{ __('Click on a map element or search above to edit it') }}</p>
</div>
@endif
@elseif($activeTab === 'features')
@@ -222,12 +222,12 @@
<table class="table table-sm table-compact">
<thead>
<tr>
<th>{{ __("Feature") }}</th>
<th>{{ __("Layer") }}</th>
<th>{{ __("Phase") }}</th>
<th>{{ __("Progress") }}</th>
<th>{{ __("Responsible") }}</th>
<th>{{ __("Template") }}</th>
<th>{{ __('Feature') }}</th>
<th>{{ __('Layer') }}</th>
<th>{{ __('Phase') }}</th>
<th>{{ __('Progress') }}</th>
<th>{{ __('Responsible') }}</th>
<th>{{ __('Template') }}</th>
<th class="w-16"></th>
</tr>
</thead>
@@ -251,7 +251,7 @@
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">📋</p>
<p>{{ __("No features found") }}</p>
<p>{{ __('No elements in this project') }}</p>
</div>
@endif
@elseif($activeTab === 'inspections')
@@ -261,10 +261,10 @@
<table class="table table-sm table-compact">
<thead>
<tr>
<th>{{ __("Date") }}</th>
<th>{{ __("Feature") }}</th>
<th>{{ __("Template") }}</th>
<th>{{ __("User") }}</th>
<th>{{ __('Date') }}</th>
<th>{{ __('Feature') }}</th>
<th>{{ __('Template') }}</th>
<th>{{ __('User') }}</th>
<th class="w-16"></th>
</tr>
</thead>
@@ -286,174 +286,249 @@
@else
<div class="text-center text-gray-400 py-8">
<p class="text-lg">📋</p>
<p>{{ __("No inspections found") }}</p>
<p>{{ __('No inspections registered') }}</p>
</div>
@endif
@endif
</div>
</div>
</div>
@push('scripts')
<style>
.leaflet-container { z-index: 0 !important; }
</style>
<script>
</div>
let map;
const layers = {};
let imageMarkersLayer = null;
let imageViewerModal = null;
function initMap() {
if (map) return;
const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('map').setView(center, 16);
@push('styles')
<style>
.leaflet-container { z-index: 0 !important; }
</style>
@endpush
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);
@push('scripts')
<script>
let map;
const layers = {};
let imageMarkersLayer = null;
let imageViewerModal = null;
let mapInitialized = false;
let combinedBounds = null;
// Cargar fases y sus features
@foreach($phases as $phase)
@php
$phaseFeatures = $phase->features()->with('layer.phase')->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
(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>
{{ __("Progress") }}: ${props.progress || 0}%<br>
{{ __("Responsible") }}: ${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 + '); });
// Utility function to escape HTML
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Utility function to validate URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
function initMap() {
// Prevent multiple initializations
if (mapInitialized || map) return;
mapInitialized = true;
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);
// Cargar fases y sus features
@foreach($phases as $phase)
@php
$phaseFeatures = $phase->features()->with('layer.phase')->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
(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;
const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
const safeProgress = escapeHtml(props.progress || 0);
const safeResponsible = escapeHtml(props.responsible || '-');
let content = `<b>${safeName}</b><br>
{{ __('Progress') }}: ${safeProgress}%<br>
{{ __('Responsible') }}: ${safeResponsible}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ {{ __('Edit') }}</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
// Initialize combined bounds
updateCombinedBounds();
setTimeout(() => {
map.invalidateSize();
zoomToAllFeatures();
}, 100);
}
function updateCombinedBounds() {
if (!map) return;
combinedBounds = 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()) {
combinedBounds.extend(b);
hasBounds = true;
}
}
}
return hasBounds;
}
function zoomToAllFeatures() {
if (!map) return;
updateCombinedBounds();
if (combinedBounds && combinedBounds.isValid()) {
map.fitBounds(combinedBounds, { padding: [20, 20] });
} else {
map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
}
}
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('{{ __('My location') }}').openPopup();
map.setView(latlng, 16);
}, () => alert('{{ __('No results') }}'));
} else {
alert('{{ __('No results') }}');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 50);
Livewire.on('layersUpdated', (activeIds) => {
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) {
layers[id].addTo(map);
updateCombinedBounds();
}
} else {
if (map.hasLayer(layers[id])) {
map.removeLayer(layers[id]);
updateCombinedBounds();
}
}
}
zoomToAllFeatures();
});
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => {
if (map) {
if (!this.resizeTimeout) {
this.resizeTimeout = setTimeout(() => {
map.invalidateSize();
this.resizeTimeout = null;
}, 100);
}
}
});
Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) {
map.removeLayer(imageMarkersLayer);
imageMarkersLayer = null;
updateCombinedBounds();
}
if (s && m && m.length > 0) {
imageMarkersLayer = L.layerGroup().addTo(map);
const photoIcon = L.divIcon({
html: '<span style="font-size: 20px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));">&#128252;</span>',
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
m.forEach(marker => {
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
const safeName = escapeHtml(marker.image_name || '');
if (safeUrl) {
const popupContent = `<b>${safeName}</b><br>
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent)
.addTo(imageMarkersLayer);
}
});
layers[{{ $phase->id }}] = phaseLayer;
@if(in_array($phase->id, $activeLayers))
phaseLayer.addTo(map);
@endif
updateCombinedBounds();
}
})();
@endforeach
});
setTimeout(() => {
map.invalidateSize();
zoomToAllFeatures();
}, 200);
}
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);
}
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').openPopup();
map.setView(latlng, 16);
}, () => alert('No se pudo obtener la ubicación'));
} else {
alert('Geolocalización no soportada');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 100);
Livewire.on('layersUpdated', (activeIds) => {
// Livewire wraps single parameters in an array, so we need to extract the actual data
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
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]);
window.openViewer = function(url, name) {
if (!isValidUrl(url)) {
console.error('Invalid URL provided to openViewer:', url);
return;
}
}
zoomToAllFeatures();
);
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); });
// Toggle imágenes en mapa
Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) { map.removeLayer(imageMarkersLayer); imageMarkersLayer = null; }
if (s && m && m.length > 0) {
imageMarkersLayer = L.layerGroup().addTo(map);
const photoIcon = L.divIcon({
html: '<span style="font-size: 20px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));">🖼️</span>',
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
m.forEach(marker => {
const popupContent = `<b>${marker.name}</b><br>
<img src="${marker.image_url}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${marker.image_url}', '${marker.image_name}')" />`;
L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent)
.addTo(imageMarkersLayer);
});
}
const safeName = escapeHtml(name);
if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div');
overlay.id = 'imageViewerModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;padding:20px;cursor:pointer';
overlay.innerHTML = `<div style="position:relative;max-width:90vw;max-height:90vh">
<button onclick="this.closest('#imageViewerModal').remove()" style="position:absolute;top:-30px;right:0;color:white;font-size:24px;background:none;border:none;cursor:pointer"></button>
<img src="${url}" alt="${safeName}" style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)" />
<p style="color:white;text-align:center;margin-top:8px;font-size:14px">${safeName}</p>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
imageViewerModal = overlay;
};
});
// Modal para ver imagen al hacer clic
window.openViewer = function(url, name) {
if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div');
overlay.id = 'imageViewerModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;padding:20px;cursor:pointer';
overlay.innerHTML = `<div style="position:relative;max-width:90vw;max-height:90vh">
<button onclick="this.closest('#imageViewerModal').remove()" style="position:absolute;top:-30px;right:0;color:white;font-size:24px;background:none;border:none;cursor:pointer"></button>
<img src="${url}" alt="${name}" style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)" />
<p style="color:white;text-align:center;margin-top:8px;font-size:14px">${name}</p>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
imageViewerModal = overlay;
};
});
</script>
</script>
@endpush