feat(inspections): add photos support with edit + lightbox

- Validación robusta fotos (10MB, jpeg/png/webp, max 10)
- Editar inspección existente: botón en historial, modal y tabla
- Eliminación individual de fotos en edición (marcar + guardar)
- Lightbox/carousel en modal ver + editar (Alpine.js)
- Columna 'Fotos' en InspectionTable con thumbnails + contador
- Permiso 'edit inspections' en seeders + checks en componente
- Test actualizado: seed roles/permisos + attach template pivot
- Fix: removed unsupported filterable() on secondaryHeaderFilter
This commit is contained in:
Javier Braña
2026-07-29 01:44:38 +02:00
parent 847ba1c2f8
commit a65d4b12f2
7 changed files with 374 additions and 13 deletions
+32 -2
View File
@@ -45,7 +45,7 @@ class InspectionTable extends DataTableComponent
return Inspection::query()
->where('inspections.project_id', $this->projectId)
->with(['feature', 'template', 'user']);
->with(['feature', 'template', 'user', 'media']);
}
public function columns(): array
@@ -78,18 +78,48 @@ class InspectionTable extends DataTableComponent
->secondaryHeaderFilter('usuario')
->label(fn ($row) => e($row->user?->name ?? '—')),
Column::make('Fotos')
->label(fn ($row) => $this->renderPhotosColumn($row))
->html(),
Column::make('Acciones')
->label(fn ($row) =>
'<div class="flex justify-end">
'<div class="flex justify-end gap-1">
<button wire:click="$dispatch(\'map-view-inspection\', { id: ' . $row->id . ' })"
class="btn btn-xs btn-ghost" title="Ver inspección">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
@can("edit inspections")
<button wire:click="$dispatch(\'edit-inspection\', { id: ' . $row->id . ' })"
class="btn btn-xs btn-ghost" title="Editar inspección">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
@endcan
</div>')
->html(),
];
}
private function renderPhotosColumn($row): string
{
$images = $row->media->where('category', 'image')->values();
$count = $images->count();
if ($count === 0) {
return '<span class="text-base-content/30 text-xs">—</span>';
}
$thumbnails = $images->take(3)->map(fn ($m) =>
'<a href="' . $m->url . '" target="_blank" class="inline-block mr-1">
<img src="' . $m->url . '" class="w-8 h-8 object-cover rounded border border-base-300" alt="' . e($m->name) . '" loading="lazy" />
</a>'
)->implode('');
$more = $count > 3 ? '<span class="text-xs text-base-content/50 ml-1">+' . ($count - 3) . '</span>' : '';
return '<div class="flex items-center">' . $thumbnails . $more . '</div>';
}
public function filters(): array
{
$results = Inspection::where('project_id', $this->projectId)
+134 -2
View File
@@ -65,6 +65,14 @@ class ProjectMap extends Component
// Inspection viewer
public $viewingInspection = null;
// Inspection editor (para editar inspecciones existentes)
public $editingInspection = null;
public $editInspectionFormData = [];
public $editInspectionResult = '';
public $editInspectionNotes = '';
public $editInspectionPhotos = [];
public $editInspectionPhotosToDelete = [];
public function mount(Project $project)
{
$this->project = $project;
@@ -271,12 +279,19 @@ class ProjectMap extends Component
$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|max:20480',
'inspectionPhotos.*' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:10240',
]);
$template = InspectionTemplate::find($this->selectedTemplateId);
@@ -406,7 +421,7 @@ class ProjectMap extends Component
'notes' => $ins->notes,
'data' => $ins->data ?? [],
'fields' => $ins->template?->fields ?? [],
'photos' => $ins->media->map(fn ($m) => ['url' => $m->url, 'name' => $m->name])->values()->all(),
'photos' => $ins->media->map(fn ($m) => ['url' => $m->url, 'name' => $m->name, 'id' => $m->id])->values()->all(),
];
}
@@ -415,6 +430,123 @@ class ProjectMap extends Component
$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 = 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) \Illuminate\Support\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');
}
// ─── Feature images ──────────────────────────────────────────────────────────
public function toggleFeatureImages()
@@ -39,6 +39,7 @@ class PermissionCatalogSeeder extends Seeder
'Inspecciones' => [
'view inspections' => 'Ver inspecciones e historial',
'create inspections' => 'Registrar inspecciones',
'edit inspections' => 'Editar inspecciones existentes',
'delete inspections' => 'Eliminar inspecciones',
'manage templates' => 'Gestionar plantillas de inspección',
],
@@ -17,7 +17,8 @@ class RolesAndPermissionsSeeder extends Seeder
// Create permissions
$permissions = [
'view projects', 'create projects', 'edit projects', 'delete projects',
'assign users', 'upload layers', 'update progress', 'view reports', 'manage all'
'assign users', 'upload layers', 'update progress', 'view reports', 'manage all',
'view inspections', 'create inspections', 'edit inspections', 'delete inspections', 'manage templates',
];
foreach ($permissions as $perm) {
Permission::firstOrCreate(['name' => $perm]);
@@ -28,7 +29,10 @@ class RolesAndPermissionsSeeder extends Seeder
$admin->givePermissionTo(Permission::all());
$supervisor = Role::firstOrCreate(['name' => 'Supervisor']);
$supervisor->givePermissionTo(['view projects', 'upload layers', 'update progress']);
$supervisor->givePermissionTo([
'view projects', 'upload layers', 'update progress',
'view inspections', 'create inspections', 'edit inspections',
]);
$consultor = Role::firstOrCreate(['name' => 'Consultor']);
$consultor->givePermissionTo(['view projects', 'view reports']);
@@ -78,8 +78,18 @@
<span class="text-xs text-gray-400">{{ __("by") }} {{ $inspection->user->name }}</span>
@endif
</div>
<div class="text-center">
<div class="flex items-center gap-1 ml-2">
<span class="badge badge-sm">{{ __("View") }}</span>
@can('edit inspections')
<button
wire:click="$dispatch('edit-inspection', { id: {{ $inspection->id } }})"
class="btn btn-xs btn-ghost btn-circle"
title="{{ __('Edit') }}"
onclick="event.stopPropagation();"
>
<x-heroicon-o-pencil class="w-4 h-4" />
</button>
@endcan
</div>
</div>
</div>
@@ -374,22 +374,197 @@
@if(!empty($viewingInspection['photos']))
<div class="divider text-xs">{{ __('Photos') }}</div>
<div class="grid grid-cols-3 gap-2">
@foreach($viewingInspection['photos'] as $ph)
<a href="{{ $ph['url'] }}" target="_blank">
<img src="{{ $ph['url'] }}" class="w-full h-20 object-cover rounded border border-base-300" alt="{{ $ph['name'] }}" />
</a>
<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">&times;</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 }}">&#8249;</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 }}">&#8250;</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
</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">&times;</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 }}">&#8249;</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 }}">&#8250;</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>
+10 -1
View File
@@ -24,7 +24,13 @@ class InspectionFormTest extends TestCase
{
Storage::fake('public');
// Seed roles and permissions
$this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
$this->seed(\Database\Seeders\PermissionCatalogSeeder::class);
$user = User::factory()->create();
$user->assignRole('Supervisor');
$project = Project::create([
'reference' => 'INS', 'name' => 'Proyecto Insp', 'address' => 'x', 'lat' => 40, 'lng' => -3,
'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonth()->toDateString(),
@@ -45,6 +51,9 @@ class InspectionFormTest extends TestCase
],
]);
// Attach template to project (pivot required for loadTemplates)
$project->inspectionTemplates()->attach($template->id);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
@@ -60,4 +69,4 @@ class InspectionFormTest extends TestCase
$this->assertEquals('5.2', $ins->data['altura']);
$this->assertCount(1, $ins->media);
}
}
}