diff --git a/app/Livewire/Projects/Traits/MapFeatures.php b/app/Livewire/Projects/Traits/MapFeatures.php new file mode 100644 index 0000000..4d15001 --- /dev/null +++ b/app/Livewire/Projects/Traits/MapFeatures.php @@ -0,0 +1,217 @@ +selectedFeature) { + return; + } + $feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); + if ($feature->layer->phase->project_id !== $this->project->id) { + abort(403); + } + $feature->status = $status; + if ($status === 'completed') { + $feature->progress = 100; + } + if ($status === 'planned') { + $feature->progress = 0; + } + $feature->save(); + $this->selectedFeature = $feature; + $this->editProgress = $feature->progress; + $this->allFeatures = $this->allFeatures->map(fn ($f) => $f->id === $feature->id ? $feature : $f); + $this->dispatch('featureStatusChanged', $feature->id, $feature->status, $feature->status_color); + $this->dispatch('notify', 'Estado actualizado'); + } + + public function updateProgress($featureId, $newProgress, $comment = null) + { + $feature = Feature::with('layer.phase')->findOrFail($featureId); + $user = Auth::user(); + if (! $user->can('update progress')) { + $this->dispatch('notify', 'Sin permisos'); + + return; + } + if ($feature->layer->phase->project_id !== $this->project->id) { + abort(403); + } + $feature->progress = min(100, max(0, $newProgress)); + $feature->save(); + $phase = $feature->layer->phase; + $phase->progress_percent = $phase->features()->avg('progress') ?: 0; + $phase->save(); + $phase->progressUpdates()->create([ + 'user_id' => $user->id, + 'progress_percent' => $phase->progress_percent, + 'comment' => $comment, + ]); + $this->dispatch('progressUpdated', $featureId, $feature->progress); + $this->dispatch('notify', 'Progreso actualizado'); + if ($this->selectedFeature && $this->selectedFeature->id == $featureId) { + $this->selectedFeature->progress = $feature->progress; + $this->editProgress = $feature->progress; + } + } + + #[On('map-select-feature')] + public function selectFeature($featureId) + { + \Log::info('[ProjectMap] map-select-feature received', ['payload' => $featureId, 'type' => gettype($featureId)]); + + // Handle both formats: direct ID or { featureId: X } + if (is_array($featureId) && isset($featureId['featureId'])) { + $featureId = $featureId['featureId']; + \Log::info('[ProjectMap] Extracted featureId from array', ['featureId' => $featureId]); + } + + $this->selectedFeature = null; + $feature = Feature::with(['template', 'layer.phase'])->find($featureId); + if (! $feature) { + \Log::warning('[ProjectMap] Feature not found', ['featureId' => $featureId]); + + return; + } + if ($feature->layer->phase->project_id !== $this->project->id) { + \Log::warning('[ProjectMap] Feature not in project', ['featureId' => $featureId, 'projectId' => $this->project->id]); + abort(403); + } + + $this->selectedFeature = $feature; + $this->selectedPhaseId = $feature->layer->phase_id; + $this->editProgress = $feature->progress; + $this->editResponsible = $feature->responsible ?? ''; + $this->editPhotos = $feature->properties['photos'] ?? []; + $this->selectedTemplateId = $feature->template_id; + $this->activeTab = 'edit'; + + $this->loadInspectionHistory(); + $this->resetInspectionForm(); + + $this->dispatch('featureSelected', $featureId, $feature->name); + } + + public function saveFeatureProgress() + { + if (! $this->selectedFeature) { + return; + } + $feature = Feature::with('layer.phase')->findOrFail($this->selectedFeature->id); + if ($feature->layer->phase->project_id !== $this->project->id) { + abort(403); + } + $feature->progress = min(100, max(0, (int) $this->editProgress)); + $feature->responsible = $this->editResponsible; + $feature->save(); + $this->selectedFeature = $feature; + $phase = $feature->layer->phase; + $phase->progress_percent = $phase->features()->avg('progress') ?: 0; + $phase->save(); + $this->dispatch('progressUpdated', $phase->id, $phase->progress_percent); + $this->dispatch('notify', 'Progreso guardado'); + } + + // ─── Feature Images ──────────────────────────────────────────────────────── + + public function toggleFeatureImages() + { + $this->showFeatureImages = ! $this->showFeatureImages; + $this->loadFeatureImageMarkers(); + $this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers); + } + + public function loadFeatureImageMarkers() + { + if (! $this->showFeatureImages) { + $this->featureImageMarkers = []; + + return; + } + $markers = []; + foreach ($this->phases as $phase) { + foreach ($phase->layers as $layer) { + foreach ($layer->features as $feature) { + $image = $feature->images->first(); + if ($image) { + $geo = $feature->geometry; + $coords = null; + if ($geo && isset($geo['coordinates'])) { + if ($geo['type'] === 'Point') { + $coords = ['lat' => $geo['coordinates'][1], 'lng' => $geo['coordinates'][0]]; + } elseif (in_array($geo['type'], ['Polygon', 'MultiPolygon'])) { + $firstRing = $geo['type'] === 'Polygon' ? $geo['coordinates'][0] : $geo['coordinates'][0][0]; + $coords = ['lat' => $firstRing[0][1], 'lng' => $firstRing[0][0]]; + } + } + if ($coords && $coords['lat'] && $coords['lng']) { + $markers[] = [ + 'feature_id' => $feature->id, + 'name' => $feature->name, + 'lat' => $coords['lat'], + 'lng' => $coords['lng'], + 'image_url' => $image->url, + 'image_name' => $image->name, + ]; + } + } + } + } + } + $this->featureImageMarkers = $markers; + } + + public function toggleFullscreen() + { + $this->formFullscreen = ! $this->formFullscreen; + if (! $this->formFullscreen) { + $this->dispatch('mapResize'); + } + } + + public function setActiveTab($tab) + { + $this->activeTab = $tab; + } +} \ No newline at end of file diff --git a/app/Livewire/Projects/Traits/MapInspections.php b/app/Livewire/Projects/Traits/MapInspections.php new file mode 100644 index 0000000..7625372 --- /dev/null +++ b/app/Livewire/Projects/Traits/MapInspections.php @@ -0,0 +1,412 @@ +templates = $this->project->inspectionTemplates() + ->orderBy('inspection_templates.name') + ->get(); + } + + public function loadInspectionHistory() + { + if (! $this->selectedFeature) { + $this->inspectionHistory = []; + + return; + } + $this->inspectionHistory = Inspection::where('feature_id', $this->selectedFeature->id) + ->with('user', 'template') + ->orderBy('created_at', 'desc') + ->get(); + } + + public function resetInspectionForm() + { + $this->inspectionFormData = []; + $this->inspectionResult = ''; + $this->inspectionNotes = ''; + $this->inspectionPhotos = []; + if ($this->selectedTemplateId) { + $template = InspectionTemplate::find($this->selectedTemplateId); + if ($template) { + foreach ($template->fields as $field) { + $this->inspectionFormData[$field['name']] = ''; + } + } + } + } + + public function saveInspection() + { + if (! $this->selectedFeature || ! $this->selectedTemplateId) { + $this->dispatch('notify', 'Selecciona un elemento y un template.'); + + return; + } + + // Verificar permiso + if (! auth()->user()->can('create inspections')) { + $this->dispatch('notify', 'Sin permisos para crear inspecciones.'); + + return; + } + + $feature = Feature::with('layer.phase')->find($this->selectedFeature->id); + if (! $feature || $feature->layer->phase->project_id !== $this->project->id) { + abort(403); + } + + $this->validate([ + 'selectedTemplateId' => 'required|exists:inspection_templates,id', + 'inspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240', + ]); + + $template = InspectionTemplate::find($this->selectedTemplateId); + foreach ($template->fields as $field) { + if (($field['required'] ?? false) && empty($this->inspectionFormData[$field['name']])) { + $this->dispatch('notify', "El campo {$field['label']} es obligatorio."); + + return; + } + } + + $inspection = Inspection::create([ + 'project_id' => $this->project->id, + 'layer_id' => $this->selectedFeature->layer_id, + 'feature_id' => $this->selectedFeature->id, + 'template_id' => $this->selectedTemplateId, + 'user_id' => auth()->id(), + 'inspector_user_id' => auth()->id(), + 'status' => 'completed', + 'completed_at' => now(), + 'result' => $this->inspectionResult ?: null, + 'notes' => $this->inspectionNotes ?: null, + 'data' => $this->inspectionFormData, + ]); + + // Fotos adjuntas a la inspección + foreach ($this->inspectionPhotos as $photo) { + $mime = $photo->getMimeType(); + $path = $photo->store("uploads/inspections/{$inspection->id}", 'public'); + $inspection->media()->create([ + 'name' => $photo->getClientOriginalName(), + 'file_path' => $path, + 'file_type' => $mime, + 'file_extension' => $photo->getClientOriginalExtension(), + 'file_size' => $photo->getSize(), + 'category' => str_starts_with($mime, 'image/') ? 'image' : 'document', + 'uploaded_by' => auth()->id(), + 'uuid' => (string) Str::uuid(), + ]); + } + + if ($this->inspectionResult === 'fail') { + Issue::create([ + 'project_id' => $this->project->id, + 'feature_id' => $this->selectedFeature->id, + 'inspection_id' => $inspection->id, + 'title' => 'Fallo en inspección: '.($template->name ?? 'Sin nombre'), + 'description' => $this->inspectionNotes, + 'priority' => 'high', + 'status' => 'open', + 'reported_by' => auth()->id(), + ]); + $this->openIssuesCount = Issue::where('project_id', $this->project->id) + ->where('status', 'open')->count(); + $this->dispatch('notify', 'Inspección fallida — Issue creado automáticamente'); + } else { + if (isset($this->inspectionFormData['progress'])) { + $this->updateProgress($this->selectedFeature->id, (int) $this->inspectionFormData['progress'], 'Inspección registrada'); + } + $this->dispatch('notify', 'Inspección guardada correctamente'); + } + + // Notificar a usuarios del proyecto (excepto creador) + $usersToNotify = $this->project->users() + ->where('user_id', '!=', auth()->id()) + ->get(); + foreach ($usersToNotify as $user) { + $user->notify(new InspectionCompletedNotification($inspection)); + } + + // Reload global list + $this->allInspections = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user']) + ->orderBy('created_at', 'desc') + ->get(); + + $this->loadInspectionHistory(); + $this->resetInspectionForm(); + } + + public function assignTemplateToFeature($templateId) + { + if (! $this->selectedFeature) { + return; + } + $template = InspectionTemplate::where('id', $templateId) + ->where('project_id', $this->project->id)->first(); + if (! $template) { + abort(403); + } + $feature = Feature::findOrFail($this->selectedFeature->id); + $feature->template_id = $templateId; + $feature->save(); + $this->selectedFeature = $feature; + $this->selectedTemplateId = $templateId; + $this->resetInspectionForm(); + $this->dispatch('notify', 'Template asignado al elemento'); + } + + // ─── Inspection viewer ────────────────────────────────────────────────────── + + #[On('map-view-inspection')] + public function viewInspection($id) + { + $ins = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user', 'media']) + ->find($id); + if (! $ins) { + return; + } + $this->viewingInspection = [ + 'id' => $ins->id, + 'feature_name' => $ins->feature?->name ?? '—', + 'layer_name' => $ins->feature?->layer?->name ?? '—', + 'phase_name' => $ins->feature?->layer?->phase?->name ?? '—', + 'template_name' => $ins->template?->name ?? '—', + 'user_name' => $ins->user?->name ?? '—', + 'date' => $ins->created_at->format('d/m/Y H:i'), + 'status' => $ins->status, + 'result' => $ins->result, + 'notes' => $ins->notes, + 'data' => $ins->data ?? [], + 'fields' => $ins->template?->fields ?? [], + 'photos' => $ins->media->map(fn ($m) => ['url' => $m->url, 'name' => $m->name, 'id' => $m->id])->values()->all(), + ]; + } + + public function closeViewInspection() + { + $this->viewingInspection = null; + } + + // ─── Inspection Editor (editar inspección existente) ───────────────────────── + + #[On('edit-inspection')] + public function editInspection($id) + { + $ins = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user', 'media']) + ->find($id); + if (! $ins) { + return; + } + + $this->editingInspection = $ins; + $this->editInspectionFormData = $ins->data ?? []; + $this->editInspectionResult = $ins->result ?? ''; + $this->editInspectionNotes = $ins->notes ?? ''; + $this->editInspectionPhotos = []; + $this->editInspectionPhotosToDelete = []; + $this->selectedTemplateId = $ins->template_id; + + $this->dispatch('openEditInspectionModal'); + } + + public function closeEditInspection() + { + $this->editingInspection = null; + $this->editInspectionFormData = []; + $this->editInspectionResult = ''; + $this->editInspectionNotes = ''; + $this->editInspectionPhotos = []; + $this->editInspectionPhotosToDelete = []; + } + + public function deleteEditPhoto($mediaIndex) + { + if (! $this->editingInspection) { + return; + } + $media = $this->editingInspection->media; + if (isset($media[$mediaIndex])) { + $m = $media[$mediaIndex]; + if (! in_array($m->id, $this->editInspectionPhotosToDelete)) { + $this->editInspectionPhotosToDelete[] = $m->id; + } + } + } + + public function saveEditInspection() + { + if (! $this->editingInspection) { + return; + } + + // Verificar permiso + if (! auth()->user()->can('edit inspections')) { + $this->dispatch('notify', 'Sin permisos para editar inspecciones.'); + + return; + } + + $this->validate([ + 'selectedTemplateId' => 'required|exists:inspection_templates,id', + 'editInspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240', + 'editInspectionPhotosToDelete' => 'array', + 'editInspectionPhotosToDelete.*' => 'exists:media,id', + ]); + + $ins = $this->editingInspection; + if ($ins->project_id !== $this->project->id) { + abort(403); + } + + $template = InspectionTemplate::find($this->selectedTemplateId); + foreach ($template->fields as $field) { + if (($field['required'] ?? false) && empty($this->editInspectionFormData[$field['name']])) { + $this->dispatch('notify', "El campo {$field['label']} es obligatorio."); + + return; + } + } + + // Eliminar fotos marcadas + if (! empty($this->editInspectionPhotosToDelete)) { + $mediaToDelete = \App\Models\Media::whereIn('id', $this->editInspectionPhotosToDelete) + ->where('mediable_type', Inspection::class) + ->where('mediable_id', $ins->id) + ->get(); + foreach ($mediaToDelete as $m) { + $m->delete(); + } + } + + // Actualizar datos + $ins->update([ + 'template_id' => $this->selectedTemplateId, + 'result' => $this->editInspectionResult ?: null, + 'notes' => $this->editInspectionNotes ?: null, + 'data' => $this->editInspectionFormData, + ]); + + // Añadir nuevas fotos + foreach ($this->editInspectionPhotos as $photo) { + $mime = $photo->getMimeType(); + $path = $photo->store("uploads/inspections/{$ins->id}", 'public'); + $ins->media()->create([ + 'name' => $photo->getClientOriginalName(), + 'file_path' => $path, + 'file_type' => $mime, + 'file_extension' => $photo->getClientOriginalExtension(), + 'file_size' => $photo->getSize(), + 'category' => str_starts_with($mime, 'image/') ? 'image' : 'document', + 'uploaded_by' => auth()->id(), + 'uuid' => (string) Str::uuid(), + ]); + } + + // Refresh + $this->allInspections = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user']) + ->orderBy('created_at', 'desc') + ->get(); + $this->loadInspectionHistory(); + + $this->closeEditInspection(); + $this->dispatch('notify', 'Inspección actualizada correctamente'); + } + + // ─── Delete Inspection ────────────────────────────────────────────────────── + + #[On('delete-inspection')] + public function deleteInspection($id) + { + \Log::info('deleteInspection: START', ['id' => $id]); + + if (! auth()->user()->can('delete inspections')) { + $this->dispatch('notify', 'Sin permisos para eliminar inspecciones.'); + \Log::info('deleteInspection: permission denied'); + + return; + } + + $ins = Inspection::where('project_id', $this->project->id) + ->with(['feature', 'media']) + ->find($id); + if (! $ins) { + \Log::info('deleteInspection: inspection not found', ['id' => $id]); + $this->dispatch('notify', 'Inspección no encontrada'); + + return; + } + + \Log::info('deleteInspection: BEFORE delete', ['id' => $ins->id, 'deleted_at' => $ins->deleted_at, 'project_id' => $ins->project_id]); + + // Delete associated media files (booted event deletes physical files) + $ins->media()->get()->each->delete(); + + $result = $ins->delete(); + \Log::info('deleteInspection: delete() returned', ['result' => $result]); + + $fresh = $ins->fresh(); + \Log::info('deleteInspection: AFTER delete', ['id' => $id, 'fresh_deleted_at' => $fresh ? $fresh->deleted_at : 'null', 'fresh_exists' => $fresh ? 'yes' : 'no']); + + // Refresh lists + $this->allInspections = Inspection::where('project_id', $this->project->id) + ->with(['feature.layer.phase', 'template', 'user']) + ->orderBy('created_at', 'desc') + ->get(); + $this->loadInspectionHistory(); + + $this->dispatch('notify', 'Inspección eliminada correctamente'); + \Log::info('deleteInspection: dispatched notify SUCCESS'); + } +} \ No newline at end of file diff --git a/app/Livewire/Projects/Traits/MapIssues.php b/app/Livewire/Projects/Traits/MapIssues.php new file mode 100644 index 0000000..ed131e4 --- /dev/null +++ b/app/Livewire/Projects/Traits/MapIssues.php @@ -0,0 +1,36 @@ +openIssuesCount = Issue::where('project_id', $this->project->id) + ->where('status', 'open') + ->count(); + } + + #[On('issue-updated')] + public function handleIssueUpdated() + { + $this->openIssuesCount = Issue::where('project_id', $this->project->id) + ->where('status', 'open') + ->count(); + } + + #[On('issue-deleted')] + public function handleIssueDeleted() + { + $this->openIssuesCount = Issue::where('project_id', $this->project->id) + ->where('status', 'open') + ->count(); + } +} \ No newline at end of file diff --git a/app/Livewire/Projects/Traits/MapLayers.php b/app/Livewire/Projects/Traits/MapLayers.php new file mode 100644 index 0000000..dfc12a7 --- /dev/null +++ b/app/Livewire/Projects/Traits/MapLayers.php @@ -0,0 +1,114 @@ +activeLayers)) { + $this->activeLayers = array_values(array_diff($this->activeLayers, [$layerId])); + } else { + $this->activeLayers[] = $layerId; + } + $this->dispatch('layersUpdated', $this->activeLayers); + } + + public function togglePhase($phaseId) + { + $phase = $this->phases->find($phaseId); + if (! $phase) { + return; + } + $layerIds = $phase->layers->pluck('id')->map(fn ($id) => (int) $id)->toArray(); + $allActive = ! empty($layerIds) && collect($layerIds)->every(fn ($id) => in_array($id, $this->activeLayers)); + if ($allActive) { + $this->activeLayers = array_values(array_diff($this->activeLayers, $layerIds)); + } else { + $this->activeLayers = array_values(array_unique(array_merge($this->activeLayers, $layerIds))); + } + $this->dispatch('layersUpdated', $this->activeLayers); + } + + public function openLayerModal() + { + $this->showLayerModal = true; + } + + public function closeLayerModal() + { + $this->showLayerModal = false; + } + + // ─── Filters ──────────────────────────────────────────────────────────────── + + public $filterStatus = ''; + + public $filterResponsible = ''; + + public $filterProgressMin = 0; + + public $filterProgressMax = 100; + + public $showFilters = false; + + public function updatedFilterStatus() + { + $this->applyFilters(); + } + + public function updatedFilterResponsible() + { + $this->applyFilters(); + } + + public function updatedFilterProgressMin() + { + $this->applyFilters(); + } + + public function updatedFilterProgressMax() + { + $this->applyFilters(); + } + + public function applyFilters() + { + $filtered = $this->allFeatures->filter(function ($f) { + if ($this->filterStatus && $f->status !== $this->filterStatus) { + return false; + } + if ($this->filterResponsible && ! str_contains(strtolower($f->responsible ?? ''), strtolower($this->filterResponsible))) { + return false; + } + if ($f->progress < $this->filterProgressMin || $f->progress > $this->filterProgressMax) { + return false; + } + + return true; + }); + $this->dispatch('filtersChanged', $filtered->pluck('id')->values()->toArray()); + } + + public function clearFilters() + { + $this->filterStatus = ''; + $this->filterResponsible = ''; + $this->filterProgressMin = 0; + $this->filterProgressMax = 100; + $this->dispatch('filtersChanged', $this->allFeatures->pluck('id')->values()->toArray()); + } +} \ No newline at end of file