- Add Task model with subtasks, priorities, status transitions, dates, hours - Add Comment polymorphic model for tasks/issues/projects - Livewire components: TaskManager (list+filters), TaskForm (modal), TaskDetail, TaskKanban (drag&drop), TaskCalendar (FullCalendar) - TaskPolicy with permissions (view/create/edit/delete/assign/manage all) - Notifications: assigned, status change, overdue, comment added - Daily overdue notification job scheduled - Dashboard widget unifies IssueTask + Task - i18n: en/es/fr/ru (393 keys each) - Routes, navigation, offline sync support - 101 tests passing, Pint compliant on new files
831 lines
55 KiB
PHP
831 lines
55 KiB
PHP
<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 x-data="{ showLayers: true }" class="w-full lg:w-2/3 flex-1 relative">
|
|
<div id="map" style="height: 100%; min-height: 500px; width: 100%;" wire:ignore></div>
|
|
|
|
<!-- Botón para reabrir el panel (solo cuando está colapsado) -->
|
|
<button x-show="!showLayers" x-cloak @click="showLayers = true"
|
|
class="absolute top-2 right-2 z-[1001] btn btn-sm btn-circle shadow-lg"
|
|
title="{{ __('Show/hide panel') }}">
|
|
<x-heroicon-o-bars-3 class="w-5 h-5" />
|
|
</button>
|
|
|
|
<!-- Panel lateral de capas -->
|
|
<div x-show="showLayers" x-transition
|
|
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">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="font-semibold text-base">{{ __('Phases and layers') }}</h3>
|
|
<button @click="showLayers = false" class="btn btn-xs btn-circle btn-ghost" title="{{ __('Show/hide panel') }}">
|
|
<x-heroicon-o-x-mark class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div class="space-y-3">
|
|
@foreach($phases as $phase)
|
|
@php
|
|
$phaseLayerIds = $phase->layers->pluck('id')->map(fn($i) => (int) $i)->all();
|
|
$phaseAllActive = count($phaseLayerIds) > 0 && collect($phaseLayerIds)->every(fn($i) => in_array($i, $activeLayers));
|
|
@endphp
|
|
<div class="border rounded-lg p-2 {{ $phaseAllActive ? 'bg-base-200' : '' }}">
|
|
{{-- Fase: el toggle muestra/oculta TODAS sus capas --}}
|
|
<div class="flex items-center gap-2">
|
|
<input type="checkbox"
|
|
wire:change="togglePhase({{ $phase->id }})"
|
|
@if($phaseAllActive) checked @endif
|
|
class="toggle toggle-xs toggle-primary"
|
|
title="{{ __('Show/hide all layers of this phase') }}">
|
|
<span style="color: {{ $phase->color }};" class="text-lg">⬤</span>
|
|
<span class="flex-1 font-medium text-sm truncate">{{ $phase->name }}</span>
|
|
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}</span>
|
|
</div>
|
|
|
|
{{-- Capas de esta fase: cada una con su propio toggle independiente --}}
|
|
@if($phase->layers->isNotEmpty())
|
|
<div class="ml-7 mt-1 space-y-1">
|
|
@foreach($phase->layers as $layer)
|
|
<div class="flex items-center gap-2 text-xs text-gray-600">
|
|
<input type="checkbox"
|
|
wire:change="toggleLayer({{ $layer->id }})"
|
|
@if(in_array((int) $layer->id, $activeLayers)) checked @endif
|
|
class="toggle toggle-xs toggle-primary"
|
|
title="{{ __('Show/hide layer') }}">
|
|
<span class="w-2 h-2 rounded-full inline-block shrink-0" style="background: {{ $layer->color ?? '#ccc' }}"></span>
|
|
<span class="flex-1 truncate">{{ $layer->name }}</span>
|
|
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} {{ __('elem.') }}</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Botón para ir a gestión de capas de esta fase --}}
|
|
<div class="mt-1 ml-7">
|
|
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary gap-1">
|
|
<x-heroicon-o-pencil-square class="w-3.5 h-3.5" /> {{ __('Manage Layers') }}
|
|
</a>
|
|
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline gap-1">
|
|
<x-heroicon-o-chart-bar class="w-3.5 h-3.5" /> {{ __('Progress') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- Checkbox imágenes en mapa --}}
|
|
<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" />
|
|
<x-heroicon-o-photo class="w-4 h-4" /> {{ __('Show images on map') }}
|
|
@if($featureImageMarkers)
|
|
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
|
|
@endif
|
|
</label>
|
|
</div>
|
|
|
|
{{-- Botones generales --}}
|
|
<div class="mt-2 space-y-1">
|
|
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full gap-1">
|
|
<x-heroicon-o-folder class="w-4 h-4" /> {{ __('Project files') }}
|
|
</a>
|
|
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full gap-1">
|
|
<x-heroicon-o-map-pin class="w-4 h-4" /> {{ __('Centered in project') }}
|
|
</button>
|
|
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full gap-1">
|
|
<x-heroicon-o-viewfinder-circle class="w-4 h-4" /> {{ __('My location') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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 gap-4 mb-4">
|
|
<!-- Tabs -->
|
|
<div class="flex flex-wrap gap-3">
|
|
<button role="tab" wire:click="setActiveTab('edit')" class="btn btn-sm {{ $activeTab === 'edit' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Edit') }}</button>
|
|
<button role="tab" wire:click="setActiveTab('features')" class="btn btn-sm {{ $activeTab === 'features' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Features') }}</button>
|
|
<button role="tab" wire:click="setActiveTab('inspections')" class="btn btn-sm {{ $activeTab === 'inspections' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Inspections') }}</button>
|
|
<button role="tab" wire:click="setActiveTab('issues')" class="btn btn-sm gap-1 {{ $activeTab === 'issues' ? 'btn-primary' : 'btn-ghost' }}">
|
|
{{ __('Issues') }}
|
|
@if($openIssuesCount > 0)
|
|
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
|
@endif
|
|
</button>
|
|
</div>
|
|
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm shrink-0" title="{{ __('Fullscreen') }}">
|
|
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Project navigation bar (hidden for now, kept for later) -->
|
|
<div class="hidden flex-wrap gap-1 mb-3">
|
|
<a href="{{ route('projects.dashboard', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" /> {{ __('Dashboard') }}
|
|
</a>
|
|
<a href="{{ route('projects.map', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-map class="w-3.5 h-3.5" /> {{ __('Map') }}
|
|
</a>
|
|
<a href="{{ route('projects.gantt', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" /> {{ __('Gantt') }}
|
|
</a>
|
|
<a href="{{ route('projects.report', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-document-text class="w-3.5 h-3.5" /> {{ __('Report') }}
|
|
</a>
|
|
<a href="{{ route('projects.issues', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }}">
|
|
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5" /> {{ __('Issues') }}
|
|
@if($openIssuesCount > 0)
|
|
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
|
@endif
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Tab Content: visibility controlled by Livewire conditionals, not DaisyUI -->
|
|
<div class="mt-2">
|
|
@if($activeTab === 'edit')
|
|
@if($selectedFeature)
|
|
{{-- Título a todo el ancho: progreso (solo número) a la izquierda + nombre --}}
|
|
<div class="flex items-center gap-3 mb-4 pb-2 border-b border-base-300">
|
|
<span class="badge badge-lg shrink-0 {{ $editProgress >= 100 ? 'badge-success' : ($editProgress > 0 ? 'badge-warning' : 'badge-ghost') }}">{{ $editProgress }}%</span>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-bold text-base truncate">{{ $selectedFeature->name ?? __('Feature') }}</h3>
|
|
<p class="text-xs text-gray-500 truncate">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }} · {{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
|
|
</div>
|
|
@can('create issues')
|
|
<a href="{{ route('projects.issues.create', ['project' => $project, 'feature' => $selectedFeature->id]) }}"
|
|
wire:navigate
|
|
class="btn btn-xs btn-error btn-outline gap-1 shrink-0" title="{{ __('Reportar incidencia') }}">
|
|
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5" />
|
|
{{ __('Incidencia') }}
|
|
</a>
|
|
@endcan
|
|
</div>
|
|
|
|
{{-- En pantalla completa el contenido se reparte en columnas --}}
|
|
<div :class="formFullscreen ? 'grid grid-cols-1 lg:grid-cols-2 gap-x-8 items-start' : ''">
|
|
<div>
|
|
{{-- Responsable (se guarda al salir del campo) --}}
|
|
<div class="form-control mb-3">
|
|
<label class="label-text">{{ __('Responsible') }}</label>
|
|
<input type="text" wire:model="editResponsible" wire:blur="saveFeatureProgress" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
|
|
</div>
|
|
|
|
{{-- 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">
|
|
<x-heroicon-o-paper-clip class="w-4 h-4 inline" /> {{ __('Files of element') }}
|
|
</summary>
|
|
<div class="p-2">
|
|
@livewire('media.media-manager', [
|
|
'mediableType' => 'App\\Models\\Feature',
|
|
'mediableId' => $selectedFeature->id,
|
|
], key('media-feature-' . $selectedFeature->id))
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<div>
|
|
{{-- Templates / Inspecciones --}}
|
|
@if($templates->isNotEmpty())
|
|
<div class="divider text-xs">{{ __('Inspection') }}</div>
|
|
<div class="form-control mb-2">
|
|
<div class="flex items-center justify-between">
|
|
<label class="label-text">{{ __('Template') }}</label>
|
|
@can('edit projects')
|
|
<a href="{{ route('projects.templates', $project) }}"
|
|
class="btn btn-ghost btn-xs gap-1" title="{{ __('Manage templates') }}">
|
|
<x-heroicon-o-cog-6-tooth class="w-3.5 h-3.5" /> {{ __('Manage templates') }}
|
|
</a>
|
|
@endcan
|
|
</div>
|
|
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
|
|
<option value="">{{ __('Select template...') }}</option>
|
|
@foreach($templates as $t)
|
|
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
@if($selectedTemplateId && !empty($inspectionFormData))
|
|
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
|
|
@if($template)
|
|
@php $grouped = collect($template->fields)->groupBy(fn ($f) => trim($f['group'] ?? '') !== '' ? $f['group'] : __('General')); @endphp
|
|
@foreach($grouped as $groupName => $groupFields)
|
|
<div class="mb-3">
|
|
<div class="text-xs font-semibold uppercase tracking-wide text-base-content/60 border-b border-base-300 pb-1 mb-2">{{ $groupName }}</div>
|
|
@foreach($groupFields as $field)
|
|
<div class="mb-2">
|
|
<label class="label-text text-xs font-medium">
|
|
{{ ($field['question'] ?? '') ?: $field['label'] }}
|
|
@if($field['required'] ?? false)<span class="text-error">*</span>@endif
|
|
</label>
|
|
@if(!empty($field['question']) && !empty($field['label']) && $field['question'] !== $field['label'])
|
|
<div class="text-[11px] text-base-content/40">{{ $field['label'] }}</div>
|
|
@endif
|
|
@switch($field['type'] ?? 'text')
|
|
@case('percentage')
|
|
<div class="flex items-center gap-1">
|
|
<input type="number" wire:model="inspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
|
|
<span class="text-xs">%</span>
|
|
<input type="range" min="0" max="100" wire:model.live="inspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
|
|
</div>
|
|
@break
|
|
@case('boolean')
|
|
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
|
|
@break
|
|
@case('select')
|
|
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
|
|
<option value="">{{ __('Select') }}</option>
|
|
@foreach(explode(',', $field['options'] ?? '') as $opt)
|
|
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
|
|
@endforeach
|
|
</select>
|
|
@break
|
|
@case('textarea')
|
|
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
|
|
@break
|
|
@default
|
|
<input type="{{ $field['type'] ?? 'text' }}" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
|
|
@endswitch
|
|
@if(!empty($field['help']))
|
|
<div class="text-[11px] text-base-content/50 mt-0.5">{{ $field['help'] }}</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endforeach
|
|
|
|
{{-- Comentarios + fotos de la inspección --}}
|
|
<div class="border-t border-base-300 pt-2 mt-2 space-y-2">
|
|
<div>
|
|
<label class="label-text text-xs font-medium">{{ __('Comments') }}</label>
|
|
<textarea wire:model="inspectionNotes" rows="2" class="textarea textarea-bordered textarea-sm w-full" placeholder="{{ __('General comments...') }}"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="label-text text-xs font-medium">{{ __('Photos') }}</label>
|
|
<input type="file" wire:model="inspectionPhotos" multiple accept="image/*" class="file-input file-input-bordered file-input-sm w-full" />
|
|
<div wire:loading wire:target="inspectionPhotos" class="text-[11px] text-base-content/50">{{ __('Uploading…') }}</div>
|
|
@error('inspectionPhotos.*')<div class="text-[11px] text-error">{{ $message }}</div>@enderror
|
|
@if($inspectionPhotos)<div class="text-[11px] text-base-content/60 mt-0.5">{{ count($inspectionPhotos) }} {{ __('photo(s) ready') }}</div>@endif
|
|
</div>
|
|
</div>
|
|
|
|
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-2" wire:loading.attr="disabled" wire:target="saveInspection,inspectionPhotos">{{ __('Register inspection') }}</button>
|
|
@endif
|
|
@endif
|
|
|
|
{{-- Historial de inspecciones --}}
|
|
@if($inspectionHistory->isNotEmpty())
|
|
<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" wire:key="hist-{{ $ins->id }}">
|
|
<div class="flex justify-between">
|
|
<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">{{ __('by') }} {{ $ins->user->name }}</span>@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
@else
|
|
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
<div>
|
|
<h3 class="font-bold">{{ __('No 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>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="text-center text-gray-400 py-8">
|
|
<x-heroicon-o-cursor-arrow-rays class="w-10 h-10 mx-auto opacity-30" />
|
|
<p>{{ __('Click on a map element or search above to edit it') }}</p>
|
|
</div>
|
|
@endif
|
|
@elseif($activeTab === 'features')
|
|
<!-- Elementos (tabla Rappasoft) -->
|
|
<livewire:projects.feature-table :project-id="$project->id" :key="'feature-table-'.$project->id" />
|
|
@elseif($activeTab === 'inspections')
|
|
<!-- Inspecciones (tabla Rappasoft) -->
|
|
<livewire:projects.inspection-table :project-id="$project->id" :key="'inspection-table-'.$project->id" />
|
|
@elseif($activeTab === 'issues')
|
|
<!-- Issues tab: render embedded IssueManager component -->
|
|
@livewire('issues.issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Visor de inspección --}}
|
|
@if($viewingInspection)
|
|
<div class="modal modal-open z-[2000]" wire:key="ins-viewer-{{ $viewingInspection['id'] }}">
|
|
<div class="modal-box max-w-lg">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<h3 class="font-bold text-lg">{{ __('Inspection') }} #{{ $viewingInspection['id'] }}</h3>
|
|
<button wire:click="closeViewInspection" class="btn btn-sm btn-circle btn-ghost">
|
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-2 text-sm mb-2">
|
|
<div><span class="text-gray-500">{{ __('Feature') }}:</span> {{ $viewingInspection['feature_name'] }}</div>
|
|
<div><span class="text-gray-500">{{ __('Template') }}:</span> {{ $viewingInspection['template_name'] }}</div>
|
|
<div><span class="text-gray-500">{{ __('Phase') }}:</span> {{ $viewingInspection['phase_name'] }}</div>
|
|
<div><span class="text-gray-500">{{ __('Layer') }}:</span> {{ $viewingInspection['layer_name'] }}</div>
|
|
<div><span class="text-gray-500">{{ __('User') }}:</span> {{ $viewingInspection['user_name'] }}</div>
|
|
<div><span class="text-gray-500">{{ __('Date') }}:</span> {{ $viewingInspection['date'] }}</div>
|
|
</div>
|
|
|
|
@if(!empty($viewingInspection['fields']))
|
|
<div class="divider text-xs">{{ __('Data') }}</div>
|
|
@php $vGrouped = collect($viewingInspection['fields'])->groupBy(fn ($f) => trim($f['group'] ?? '') !== '' ? $f['group'] : __('General')); @endphp
|
|
@foreach($vGrouped as $gName => $gFields)
|
|
<div class="mb-2">
|
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-base-content/50 mb-1">{{ $gName }}</div>
|
|
<div class="space-y-1 text-sm">
|
|
@foreach($gFields as $field)
|
|
@php $val = $viewingInspection['data'][$field['name']] ?? null; @endphp
|
|
<div class="flex justify-between gap-3 border-b border-base-200 py-1">
|
|
<span class="text-gray-500">{{ ($field['question'] ?? '') ?: ($field['label'] ?? ($field['name'] ?? '')) }}</span>
|
|
<span class="font-medium text-right">
|
|
@if(($field['type'] ?? '') === 'boolean'){{ $val ? '✓' : '—' }}@else{{ ($val === '' || $val === null) ? '—' : $val }}@endif
|
|
</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
@endif
|
|
|
|
@if(!empty($viewingInspection['notes']))
|
|
<div class="divider text-xs">{{ __('Comments') }}</div>
|
|
<p class="text-sm whitespace-pre-line">{{ $viewingInspection['notes'] }}</p>
|
|
@endif
|
|
|
|
@if(!empty($viewingInspection['photos']))
|
|
<div class="divider text-xs">{{ __('Photos') }}</div>
|
|
<div class="grid grid-cols-3 gap-2" x-data="{ lightboxOpen: false, lightboxIndex: 0 }">
|
|
@foreach($viewingInspection['photos'] as $idx => $ph)
|
|
<button
|
|
type="button"
|
|
@click="lightboxOpen = true; lightboxIndex = {{ $idx }}"
|
|
class="relative w-full h-20 overflow-hidden rounded border border-base-300"
|
|
>
|
|
<img src="{{ $ph['url'] }}" class="w-full h-full object-cover" alt="{{ $ph['name'] }}" />
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- Lightbox Modal --}}
|
|
<div x-show="lightboxOpen" x-transition x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/90">
|
|
<button @click="lightboxOpen = false" class="absolute top-4 right-4 text-white/70 hover:text-white text-3xl" aria-label="Close">×</button>
|
|
<button @click="lightboxIndex = (lightboxIndex - 1 + {{ count($viewingInspection['photos']) }}) % {{ count($viewingInspection['photos']) }}" class="absolute left-4 text-white/70 hover:text-white text-4xl" aria-label="Previous" x-show="{{ count($viewingInspection['photos']) > 1 }}">‹</button>
|
|
<button @click="lightboxIndex = (lightboxIndex + 1) % {{ count($viewingInspection['photos']) }}" class="absolute right-4 text-white/70 hover:text-white text-4xl" aria-label="Next" x-show="{{ count($viewingInspection['photos']) > 1 }}">›</button>
|
|
|
|
<template x-for="(ph, i) in @js($viewingInspection['photos'])" :key="i">
|
|
<img
|
|
x-show="lightboxIndex === i"
|
|
:src="ph.url"
|
|
class="max-h-[80vh] max-w-[90vw] object-contain rounded"
|
|
:alt="ph.name"
|
|
>
|
|
</template>
|
|
|
|
<div x-show="lightboxIndex !== null" class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm">
|
|
<span x-text="lightboxIndex + 1"></span> / {{ count($viewingInspection['photos']) }}
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="modal-action">
|
|
<button wire:click="closeViewInspection" class="btn btn-sm">{{ __('Close') }}</button>
|
|
@can('edit inspections')
|
|
<button
|
|
wire:click="$dispatch('edit-inspection', { id: {{ $viewingInspection['id'] }} })"
|
|
wire:loading.attr="disabled"
|
|
class="btn btn-primary btn-sm"
|
|
>
|
|
<x-heroicon-o-pencil class="w-4 h-4 mr-1" /> {{ __('Edit') }}
|
|
</button>
|
|
@endcan
|
|
@can('delete inspections')
|
|
<button
|
|
wire:click="$dispatch('delete-inspection', { id: {{ $viewingInspection['id'] }} })"
|
|
wire:loading.attr="disabled"
|
|
class="btn btn-error btn-sm"
|
|
onclick="return confirm('{{ __('Delete this inspection? This cannot be undone.') }}');"
|
|
>
|
|
<x-heroicon-o-trash class="w-4 h-4 mr-1" /> {{ __('Delete') }}
|
|
</button>
|
|
@endcan
|
|
</div>
|
|
</div>
|
|
<div class="modal-backdrop bg-black/40" wire:click="closeViewInspection"></div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Modal Editar Inspección --}}
|
|
@if($editingInspection)
|
|
<div class="modal modal-open z-[2000]" wire:key="edit-inspection-{{ $editingInspection->id }}">
|
|
<div class="modal-box max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<h3 class="font-bold text-lg">{{ __('Edit Inspection') }} #{{ $editingInspection->id }}</h3>
|
|
<button wire:click="closeEditInspection" class="btn btn-sm btn-circle btn-ghost">
|
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-2 text-sm mb-2">
|
|
<div><span class="text-gray-500">{{ __('Feature') }}:</span> {{ $editingInspection->feature?->name ?? '—' }}</div>
|
|
<div><span class="text-gray-500">{{ __('Template') }}:</span> {{ $editingInspection->template?->name ?? '—' }}</div>
|
|
<div><span class="text-gray-500">{{ __('Phase') }}:</span> {{ $editingInspection->feature?->layer?->phase?->name ?? '—' }}</div>
|
|
<div><span class="text-gray-500">{{ __('Layer') }}:</span> {{ $editingInspection->feature?->layer?->name ?? '—' }}</div>
|
|
</div>
|
|
|
|
@if($editingInspection->template && !empty($editingInspection->template->fields))
|
|
<div class="divider text-xs">{{ __('Data') }}</div>
|
|
@php $eGrouped = collect($editingInspection->template->fields)->groupBy(fn ($f) => trim($f['group'] ?? '') !== '' ? $f['group'] : __('General')); @endphp
|
|
@foreach($eGrouped as $gName => $gFields)
|
|
<div class="mb-3">
|
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-base-content/50 mb-1">{{ $gName }}</div>
|
|
<div class="space-y-1 text-sm">
|
|
@foreach($gFields as $field)
|
|
<div class="mb-2">
|
|
<label class="label-text text-xs font-medium">
|
|
{{ ($field['question'] ?? '') ?: $field['label'] }}
|
|
@if($field['required'] ?? false)<span class="text-error">*</span>@endif
|
|
</label>
|
|
@switch($field['type'] ?? 'text')
|
|
@case('percentage')
|
|
<div class="flex items-center gap-1">
|
|
<input type="number" wire:model="editInspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
|
|
<span class="text-xs">%</span>
|
|
<input type="range" min="0" max="100" wire:model.live="editInspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
|
|
</div>
|
|
@break
|
|
@case('boolean')
|
|
<input type="checkbox" wire:model="editInspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
|
|
@break
|
|
@case('select')
|
|
<select wire:model="editInspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
|
|
<option value="">{{ __('Select') }}</option>
|
|
@foreach(explode(',', $field['options'] ?? '') as $opt)
|
|
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
|
|
@endforeach
|
|
</select>
|
|
@break
|
|
@case('textarea')
|
|
<textarea wire:model="editInspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
|
|
@break
|
|
@default
|
|
<input type="{{ $field['type'] ?? 'text' }}" wire:model="editInspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
|
|
@endswitch
|
|
@if(!empty($field['help']))
|
|
<div class="text-[11px] text-base-content/50 mt-0.5">{{ $field['help'] }}</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
@endif
|
|
|
|
<div class="border-t border-base-300 pt-2 mt-2 space-y-2">
|
|
<div>
|
|
<label class="label-text text-xs font-medium">{{ __('Result') }}</label>
|
|
<select wire:model="editInspectionResult" class="select select-bordered select-sm w-full">
|
|
<option value="">{{ __('Select result') }}</option>
|
|
<option value="pass">{{ __('Pass') }}</option>
|
|
<option value="fail">{{ __('Fail') }}</option>
|
|
<option value="conditional">{{ __('Conditional') }}</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="label-text text-xs font-medium">{{ __('Comments') }}</label>
|
|
<textarea wire:model="editInspectionNotes" rows="2" class="textarea textarea-bordered textarea-sm w-full" placeholder="{{ __('General comments...') }}"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="label-text text-xs font-medium">{{ __('Photos') }}</label>
|
|
<input type="file" wire:model="editInspectionPhotos" multiple accept="image/*" class="file-input file-input-bordered file-input-sm w-full" />
|
|
<div wire:loading wire:target="editInspectionPhotos" class="text-[11px] text-base-content/50">{{ __('Uploading…') }}</div>
|
|
@error('editInspectionPhotos.*')<div class="text-[11px] text-error">{{ $message }}</div>@enderror
|
|
@if($editInspectionPhotos)<div class="text-[11px] text-base-content/60 mt-0.5">{{ count($editInspectionPhotos) }} {{ __('photo(s) ready') }}</div>@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Fotos existentes --}}
|
|
@if($editingInspection->media->isNotEmpty())
|
|
<div class="divider text-xs">{{ __('Current photos') }}</div>
|
|
<div class="grid grid-cols-3 gap-2" x-data="{ lightboxOpen: false, lightboxIndex: 0 }">
|
|
@foreach($editingInspection->media as $idx => $m)
|
|
<div class="relative border border-base-300 rounded">
|
|
<button
|
|
type="button"
|
|
@click="lightboxOpen = true; lightboxIndex = {{ $idx }}"
|
|
class="w-full h-20 overflow-hidden rounded-t"
|
|
>
|
|
<img src="{{ $m->url }}" class="w-full h-full object-cover" alt="{{ $m->name }}" />
|
|
</button>
|
|
<div class="p-1 flex justify-between">
|
|
<span class="text-[10px] truncate">{{ $m->name }}</span>
|
|
<button wire:click="deleteEditPhoto({{ $idx }})" class="btn btn-xs btn-error btn-circle" title="{{ __('Delete photo') }}">
|
|
<x-heroicon-o-trash class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- Lightbox Modal --}}
|
|
<div x-show="lightboxOpen" x-transition x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/90">
|
|
<button @click="lightboxOpen = false" class="absolute top-4 right-4 text-white/70 hover:text-white text-3xl" aria-label="Close">×</button>
|
|
<button @click="lightboxIndex = (lightboxIndex - 1 + {{ $editingInspection->media->count() }}) % {{ $editingInspection->media->count() }}" class="absolute left-4 text-white/70 hover:text-white text-4xl" aria-label="Previous" x-show="{{ $editingInspection->media->count() > 1 }}">‹</button>
|
|
<button @click="lightboxIndex = (lightboxIndex + 1) % {{ $editingInspection->media->count() }}" class="absolute right-4 text-white/70 hover:text-white text-4xl" aria-label="Next" x-show="{{ $editingInspection->media->count() > 1 }}">›</button>
|
|
|
|
<template x-for="(m, i) in @js($editingInspection->media)" :key="i">
|
|
<img
|
|
x-show="lightboxIndex === i"
|
|
:src="m.url"
|
|
class="max-h-[80vh] max-w-[90vw] object-contain rounded"
|
|
:alt="m.name"
|
|
>
|
|
</template>
|
|
|
|
<div x-show="lightboxIndex !== null" class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm">
|
|
<span x-text="lightboxIndex + 1"></span> / {{ $editingInspection->media->count() }}
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="modal-action mt-3">
|
|
<button wire:click="closeEditInspection" class="btn btn-sm">{{ __('Cancel') }}</button>
|
|
<button wire:click="saveEditInspection" class="btn btn-primary btn-sm" wire:loading.attr="disabled" wire:target="saveEditInspection,editInspectionPhotos">{{ __('Save changes') }}</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-backdrop bg-black/40" wire:click="closeEditInspection"></div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@push('styles')
|
|
<style>
|
|
.leaflet-container { z-index: 0 !important; }
|
|
</style>
|
|
@endpush
|
|
|
|
@push('scripts')
|
|
<script>
|
|
let map;
|
|
const layers = {};
|
|
let imageMarkersLayer = null;
|
|
let imageViewerModal = null;
|
|
let mapInitialized = false;
|
|
let combinedBounds = null;
|
|
|
|
// Utility function to escape HTML
|
|
function escapeHtml(text) {
|
|
if (text === null || text === undefined) return '';
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// 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);
|
|
|
|
// Capas base seleccionables (calles / OSM / satélite)
|
|
const baseLayers = {
|
|
'{{ __('Streets') }}': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
|
}),
|
|
'OpenStreetMap': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
}),
|
|
'{{ __('Satellite') }}': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
attribution: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics'
|
|
}),
|
|
};
|
|
baseLayers['{{ __('Streets') }}'].addTo(map);
|
|
L.control.layers(baseLayers, null, { position: 'topleft' }).addTo(map);
|
|
|
|
// Cargar capas y sus features (cada capa = un grupo Leaflet independiente)
|
|
@foreach($phases as $phase)
|
|
@foreach($phase->layers as $layer)
|
|
@php
|
|
$fc = [
|
|
'type' => 'FeatureCollection',
|
|
'features' => $layer->features->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 layerGroup = L.geoJSON(data, {
|
|
style: { color: '{{ $layer->color ?? $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
|
|
onEachFeature: function(feature, lyr) {
|
|
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>`;
|
|
lyr.bindPopup(content);
|
|
lyr.on('click', function() { selectFeature(featId); });
|
|
}
|
|
});
|
|
layers[{{ $layer->id }}] = layerGroup;
|
|
@if(in_array((int) $layer->id, $activeLayers))
|
|
layerGroup.addTo(map);
|
|
@endif
|
|
}
|
|
})()
|
|
@endforeach
|
|
@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));">📼</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);
|
|
}
|
|
});
|
|
updateCombinedBounds();
|
|
}
|
|
});
|
|
|
|
window.openViewer = function(url, name) {
|
|
if (!isValidUrl(url)) {
|
|
console.error('Invalid URL provided to openViewer:', url);
|
|
return;
|
|
}
|
|
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;
|
|
};
|
|
});
|
|
</script>
|
|
@endpush
|