From 8f7b9aa09b18b2f4f7033789ef26ecf97501782f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bra=C3=B1a?= Date: Sat, 9 May 2026 22:28:20 +0200 Subject: [PATCH] =?UTF-8?q?Sistema=20de=20archivos=20multimedia:=20MediaMa?= =?UTF-8?q?nager,=20checkbox=20im=C3=A1genes=20en=20mapa,=20modal=20visor,?= =?UTF-8?q?=20subida=20por=20feature/proyecto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Livewire/MediaManager.php | 169 ++++++++++++++++++ app/Livewire/ProjectMap.php | 62 +++++++ app/Models/Feature.php | 10 ++ app/Models/Layer.php | 5 + app/Models/Media.php | 74 ++++++++ app/Models/Phase.php | 10 ++ app/Models/Project.php | 10 ++ .../2026_05_09_210000_create_media_table.php | 31 ++++ .../views/livewire/media-manager.blade.php | 120 +++++++++++++ .../livewire/projects/project-map.blade.php | 71 +++++++- resources/views/projects/media.blade.php | 20 +++ routes/web.php | 5 + 12 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 app/Livewire/MediaManager.php create mode 100644 app/Models/Media.php create mode 100644 database/migrations/2026_05_09_210000_create_media_table.php create mode 100644 resources/views/livewire/media-manager.blade.php create mode 100644 resources/views/projects/media.blade.php diff --git a/app/Livewire/MediaManager.php b/app/Livewire/MediaManager.php new file mode 100644 index 0000000..b5a010f --- /dev/null +++ b/app/Livewire/MediaManager.php @@ -0,0 +1,169 @@ + 'required|file|max:102400', // 100MB total + 'uploadDescription' => 'nullable|string|max:500', + 'uploadCategory' => 'required|in:image,document,other', + ]; + + protected $messages = [ + 'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 100MB.', + ]; + + public function mount($mediableType, $mediableId) + { + $this->mediableType = $mediableType; + $this->mediableId = $mediableId; + + $this->entity = $mediableType::findOrFail($mediableId); + $this->loadMedia(); + } + + public function loadMedia() + { + $this->mediaItems = Media::where('mediable_type', $this->mediableType) + ->where('mediable_id', $this->mediableId) + ->with('uploader') + ->latest() + ->get(); + } + + public function upload() + { + $user = Auth::user(); + if (!$user->can('upload layers') && !$user->hasRole('Admin')) { + session()->flash('error', 'Sin permisos.'); + return; + } + + $this->validate(); + + if (empty($this->uploadFiles)) { + session()->flash('error', 'Selecciona al menos un archivo.'); + return; + } + + $uploaded = 0; + foreach ($this->uploadFiles as $file) { + $mime = $file->getMimeType(); + $ext = $file->getClientOriginalExtension(); + $size = $file->getSize(); + $name = $file->getClientOriginalName(); + + // Determinar categoría automática + $category = $this->uploadCategory; + if (str_starts_with($mime, 'image/')) { + $category = 'image'; + } elseif (in_array($mime, ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) { + $category = 'document'; + } + + // Guardar en disco + $entityType = class_basename($this->entity); + $dir = "uploads/{$entityType}s/{$this->mediableId}/media"; + $path = $file->store($dir, 'public'); + + Media::create([ + 'mediable_type' => $this->mediableType, + 'mediable_id' => $this->mediableId, + 'name' => $name, + 'file_path' => $path, + 'file_type' => $mime, + 'file_extension' => $ext, + 'file_size' => $size, + 'category' => $category, + 'description' => $this->uploadDescription, + 'uploaded_by' => $user->id, + ]); + + $uploaded++; + } + + $this->reset(['uploadFiles', 'uploadDescription']); + $this->loadMedia(); + + // Notificar al mapa si corresponde + $this->dispatch('mediaUploaded', [ + 'mediableType' => $this->mediableType, + 'mediableId' => $this->mediableId, + ]); + + session()->flash('message', "$uploaded archivo(s) subido(s) correctamente."); + } + + public function deleteMedia($mediaId) + { + $media = Media::findOrFail($mediaId); + + $user = Auth::user(); + if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) { + session()->flash('error', 'No puedes borrar archivos de otro usuario.'); + return; + } + + $media->delete(); + $this->loadMedia(); + session()->flash('message', 'Archivo eliminado.'); + } + + public function viewMedia($mediaId) + { + $media = Media::findOrFail($mediaId); + if (!$media->is_image) { + // Si no es imagen, abrir en nueva pestaña + $this->dispatch('openUrl', $media->url); + return; + } + $this->viewingMedia = $media; + $this->showViewer = true; + } + + public function closeViewer() + { + $this->showViewer = false; + $this->viewingMedia = null; + } + + public function render() + { + return view('livewire.media-manager', [ + 'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id), + 'images' => $this->mediaItems->filter(fn($m) => $m->is_image), + 'documents' => $this->mediaItems->filter(fn($m) => !$m->is_image), + ]); + } +} \ No newline at end of file diff --git a/app/Livewire/ProjectMap.php b/app/Livewire/ProjectMap.php index d6363a1..423e52e 100644 --- a/app/Livewire/ProjectMap.php +++ b/app/Livewire/ProjectMap.php @@ -33,6 +33,10 @@ class ProjectMap extends Component public $inspectionFormData = []; public $inspectionHistory = []; + // Imágenes en mapa + public $showFeatureImages = false; + public $featureImageMarkers = []; + public function mount(Project $project) { $this->project = $project; @@ -246,6 +250,64 @@ class ProjectMap extends Component $this->resetInspectionForm(); } + /** + * Toggle mostrar imágenes en el mapa. + */ + public function toggleFeatureImages() + { + $this->showFeatureImages = !$this->showFeatureImages; + $this->loadFeatureImageMarkers(); + $this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers); + } + + /** + * Cargar marcadores de imágenes para el mapa. + */ + 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', 'LineString'])) { + $coords = [ + 'lat' => $geo['coordinates'][0][1] ?? null, + 'lng' => $geo['coordinates'][0][0] ?? null, + ]; + } + } + 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; diff --git a/app/Models/Feature.php b/app/Models/Feature.php index 837b583..6c8f217 100644 --- a/app/Models/Feature.php +++ b/app/Models/Feature.php @@ -29,4 +29,14 @@ class Feature extends Model { return $this->hasMany(Inspection::class, 'feature_id'); } + + public function media() + { + return $this->morphMany(Media::class, 'mediable'); + } + + public function images() + { + return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); + } } \ No newline at end of file diff --git a/app/Models/Layer.php b/app/Models/Layer.php index 626ea92..86b2e9c 100644 --- a/app/Models/Layer.php +++ b/app/Models/Layer.php @@ -33,4 +33,9 @@ class Layer extends Model { return $this->hasMany(Feature::class); } + + public function media() + { + return $this->morphMany(Media::class, 'mediable'); + } } \ No newline at end of file diff --git a/app/Models/Media.php b/app/Models/Media.php new file mode 100644 index 0000000..677c8cf --- /dev/null +++ b/app/Models/Media.php @@ -0,0 +1,74 @@ + 'array', + 'file_size' => 'integer', + ]; + + // Relación polimórfica: pertenece a Project, Phase, Layer o Feature + public function mediable() + { + return $this->morphTo(); + } + + public function uploader() + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + // Helper: URL pública del archivo + public function getUrlAttribute() + { + return Storage::url($this->file_path); + } + + // Helper: si es imagen + public function getIsImageAttribute() + { + return str_starts_with($this->file_type, 'image/'); + } + + // Helper: tamaño formateado + public function getFormattedSizeAttribute() + { + $bytes = $this->file_size; + if ($bytes >= 1073741824) return round($bytes / 1073741824, 2) . ' GB'; + if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB'; + if ($bytes >= 1024) return round($bytes / 1024) . ' KB'; + return $bytes . ' B'; + } + + // Scopes + public function scopeImages($query) + { + return $query->where('category', 'image'); + } + + public function scopeDocuments($query) + { + return $query->where('category', 'document'); + } + + // Boot: borrar archivo físico al eliminar registro + protected static function booted() + { + static::deleting(function ($media) { + Storage::delete($media->file_path); + }); + } +} \ No newline at end of file diff --git a/app/Models/Phase.php b/app/Models/Phase.php index e3cb9d3..f1605d7 100644 --- a/app/Models/Phase.php +++ b/app/Models/Phase.php @@ -38,4 +38,14 @@ class Phase extends Model { return $this->hasManyThrough(Feature::class, Layer::class); } + + public function media() + { + return $this->morphMany(Media::class, 'mediable'); + } + + public function images() + { + return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); + } } \ No newline at end of file diff --git a/app/Models/Project.php b/app/Models/Project.php index ca3953d..35d782e 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -39,6 +39,16 @@ class Project extends Model return $this->belongsTo(User::class, 'created_by'); } + public function media() + { + return $this->morphMany(Media::class, 'mediable'); + } + + public function images() + { + return $this->morphMany(Media::class, 'mediable')->where('category', 'image'); + } + // Scope to filter accessible projects for non-admin users public function scopeAccessibleBy($query, User $user) { diff --git a/database/migrations/2026_05_09_210000_create_media_table.php b/database/migrations/2026_05_09_210000_create_media_table.php new file mode 100644 index 0000000..0acf9d3 --- /dev/null +++ b/database/migrations/2026_05_09_210000_create_media_table.php @@ -0,0 +1,31 @@ +id(); + $table->morphs('mediable'); // project, phase, layer, feature + $table->string('name'); + $table->string('file_path'); + $table->string('file_type'); // image/jpeg, application/pdf, etc. + $table->string('file_extension', 10); + $table->unsignedInteger('file_size'); + $table->enum('category', ['image', 'document', 'other'])->default('image'); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); // EXIF, GPS coords, etc. + $table->foreignId('uploaded_by')->constrained('users'); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('media'); + } +}; \ No newline at end of file diff --git a/resources/views/livewire/media-manager.blade.php b/resources/views/livewire/media-manager.blade.php new file mode 100644 index 0000000..36d6398 --- /dev/null +++ b/resources/views/livewire/media-manager.blade.php @@ -0,0 +1,120 @@ +
+ @if(session()->has('message')) +
{{ session('message') }}
+ @endif + @if(session()->has('error')) +
{{ session('error') }}
+ @endif + + {{-- Subida --}} +
+
+

Subir archivos

+ +
+
+ + + @error('uploadFiles.*') {{ $message }} @enderror +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {{-- Galería de imágenes --}} + @if($images->isNotEmpty()) +
+
+

Imágenes ({{ $images->count() }})

+
+ @foreach($images as $media) +
+ {{ $media->name }} +
+ {{ $media->name }} +
+ +
+ @endforeach +
+
+
+ @endif + + {{-- Documentos --}} + @if($documents->isNotEmpty()) +
+
+

Documentos ({{ $documents->count() }})

+
+ @foreach($documents as $media) +
+ + @switch($media->file_extension) + @case('pdf') 📄 @break + @case('doc') @case('docx') 📝 @break + @case('xls') @case('xlsx') 📊 @break + @default 📎 + @endswitch + + + {{ $media->name }} + + {{ $media->formatted_size }} + +
+ @endforeach +
+
+
+ @endif + + {{-- Vacío --}} + @if($mediaItems->isEmpty()) +
+

📁

+

No hay archivos. Sube imágenes o documentos.

+
+ @endif + + {{-- Modal visor de imágenes --}} + @if($showViewer && $viewingMedia) +
+
+ + {{ $viewingMedia->name }} +
+ {{ $viewingMedia->name }} + @if($viewingMedia->description) + — {{ $viewingMedia->description }} + @endif +
+
+
+ @endif +
\ No newline at end of file diff --git a/resources/views/livewire/projects/project-map.blade.php b/resources/views/livewire/projects/project-map.blade.php index d4acfce..b586459 100644 --- a/resources/views/livewire/projects/project-map.blade.php +++ b/resources/views/livewire/projects/project-map.blade.php @@ -46,8 +46,22 @@ @endforeach + {{-- Checkbox imágenes en mapa --}} +
+ +
+ {{-- Botones generales --}} -
+
+ + 📁 Archivos del proyecto + @@ -95,6 +109,19 @@ 💾 Guardar progreso + {{-- Gestor de archivos del feature --}} +
+ + 📎 Archivos del elemento + +
+ @livewire('media-manager', [ + 'mediableType' => 'App\\Models\\Feature', + 'mediableId' => $selectedFeature->id, + ], key('media-feature-' . $selectedFeature->id)) +
+
+ {{-- Templates / Inspecciones --}} @if($templates->isNotEmpty())
Inspección
@@ -190,6 +217,8 @@ @endpush \ No newline at end of file diff --git a/resources/views/projects/media.blade.php b/resources/views/projects/media.blade.php new file mode 100644 index 0000000..b5dc51e --- /dev/null +++ b/resources/views/projects/media.blade.php @@ -0,0 +1,20 @@ + + +

+ Archivos del proyecto: {{ $project->name }} +

+
+ +
+
+ + + @livewire('media-manager', [ + 'mediableType' => 'App\Models\Project', + 'mediableId' => $project->id, + ]) +
+
+
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 6f51335..239d902 100644 --- a/routes/web.php +++ b/routes/web.php @@ -88,6 +88,11 @@ Route::middleware(['auth'])->group(function () { // Rutas para el LayerManager: Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\LayerManager::class)->name('layers.manage'); + // Gestor de medios + Route::get('/projects/{project}/media', function (\App\Models\Project $project) { + return view('projects.media', compact('project')); + })->name('projects.media'); + // ------------------------------------------------------------ // Sincronización offline (para trabajadores en campo) // ------------------------------------------------------------