feat(inspections): formulario y visor por grupos + comentarios + fotos

Parte 2 del bloque de plantillas/inspecciones:
- Formulario de inspección (mapa): campos agrupados por sección, muestra la
  "pregunta" del campo (con la etiqueta como subtexto) y la ayuda; al final,
  apartado de Comentarios (notes) y subida de Fotos (media en la inspección).
- ProjectMap: WithFileUploads + inspectionPhotos; saveInspection adjunta las fotos
  como media; Inspection gana relación media().
- Visor de inspección: datos agrupados por sección con pregunta/valor, comentarios
  y galería de fotos.

Tests: InspectionFormTest (registra inspección con comentarios + foto). Suite 91 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:51:42 +02:00
co-authored by Claude Opus 4.8
parent 44f125293b
commit 1697c16136
4 changed files with 183 additions and 39 deletions
+27 -2
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Projects;
use Livewire\Component;
use Livewire\Attributes\On;
use Livewire\WithFileUploads;
use Illuminate\Support\Facades\Auth;
use App\Models\Project;
use App\Models\Phase;
@@ -15,6 +16,8 @@ use App\Models\Issue;
class ProjectMap extends Component
{
use WithFileUploads;
public Project $project;
public $phases;
public $activeLayers = []; // Now stores Layer IDs (not Phase IDs)
@@ -54,6 +57,7 @@ class ProjectMap extends Component
// Inspection workflow
public $inspectionResult = '';
public $inspectionNotes = '';
public $inspectionPhotos = [];
// Issues
public $openIssuesCount = 0;
@@ -247,6 +251,7 @@ class ProjectMap extends Component
$this->inspectionFormData = [];
$this->inspectionResult = '';
$this->inspectionNotes = '';
$this->inspectionPhotos = [];
if ($this->selectedTemplateId) {
$template = InspectionTemplate::find($this->selectedTemplateId);
if ($template) {
@@ -266,7 +271,10 @@ class ProjectMap extends Component
$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']);
$this->validate([
'selectedTemplateId' => 'required|exists:inspection_templates,id',
'inspectionPhotos.*' => 'nullable|image|max:20480',
]);
$template = InspectionTemplate::find($this->selectedTemplateId);
foreach ($template->fields as $field) {
@@ -290,6 +298,22 @@ class ProjectMap extends Component
'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) \Illuminate\Support\Str::uuid(),
]);
}
if ($this->inspectionResult === 'fail') {
Issue::create([
'project_id' => $this->project->id,
@@ -363,7 +387,7 @@ class ProjectMap extends Component
public function viewInspection($id)
{
$ins = Inspection::where('project_id', $this->project->id)
->with(['feature.layer.phase', 'template', 'user'])
->with(['feature.layer.phase', 'template', 'user', 'media'])
->find($id);
if (!$ins) return;
$this->viewingInspection = [
@@ -379,6 +403,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(),
];
}
+5
View File
@@ -49,6 +49,11 @@ class Inspection extends Model
return $this->belongsTo(User::class, 'inspector_user_id');
}
public function media()
{
return $this->morphMany(Media::class, 'mediable');
}
public function feature()
{
return $this->belongsTo(Feature::class, 'feature_id');
@@ -214,37 +214,68 @@
@if($selectedTemplateId && !empty($inspectionFormData))
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
@if($template)
@foreach($template->fields as $field)
<div class="mb-2">
<label class="label-text text-xs">{{ $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="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
@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
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
{{-- 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
@@ -317,21 +348,41 @@
@if(!empty($viewingInspection['fields']))
<div class="divider text-xs">{{ __('Data') }}</div>
<div class="space-y-1 text-sm">
@foreach($viewingInspection['fields'] as $field)
<div class="flex justify-between gap-3 border-b border-base-200 py-1">
<span class="text-gray-500">{{ $field['label'] ?? ($field['name'] ?? '') }}</span>
<span class="font-medium text-right">{{ $viewingInspection['data'][$field['name']] ?? '—' }}</span>
@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>
@endforeach
</div>
</div>
@endforeach
@endif
@if(!empty($viewingInspection['notes']))
<div class="divider text-xs">{{ __('Notes') }}</div>
<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">
@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>
@endforeach
</div>
@endif
<div class="modal-action">
<button wire:click="closeViewInspection" class="btn btn-sm">{{ __('Close') }}</button>
</div>
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Tests\Feature;
use App\Livewire\Projects\ProjectMap;
use App\Models\Feature;
use App\Models\Inspection;
use App\Models\InspectionTemplate;
use App\Models\Layer;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class InspectionFormTest extends TestCase
{
use RefreshDatabase;
public function test_register_inspection_with_comments_and_photo(): void
{
Storage::fake('public');
$user = User::factory()->create();
$project = Project::create([
'reference' => 'INS', 'name' => 'Proyecto Insp', 'address' => 'x', 'lat' => 40, 'lng' => -3,
'start_date' => now()->toDateString(), 'end_date_estimated' => now()->addMonth()->toDateString(),
'status' => 'in_progress', 'created_by' => $user->id,
]);
$project->users()->attach($user->id, ['role_in_project' => 'supervisor']);
$phase = Phase::create(['project_id' => $project->id, 'name' => 'F1', 'order' => 1, 'color' => '#000', 'progress_percent' => 0]);
$layer = Layer::create(['project_id' => $project->id, 'phase_id' => $phase->id, 'name' => 'L', 'color' => '#111', 'uploaded_by' => $user->id]);
$feature = Feature::create([
'layer_id' => $layer->id, 'name' => 'Pilar', 'geometry' => ['type' => 'Point', 'coordinates' => [-3, 40]],
'progress' => 0, 'status' => 'planned',
]);
$template = InspectionTemplate::create([
'project_id' => $project->id, 'name' => 'Recepción',
'fields' => [
['group' => 'Geometría', 'name' => 'altura', 'label' => 'Altura', 'question' => '¿Cota OK?', 'type' => 'text', 'required' => false, 'help' => 'Medir'],
],
]);
Livewire::actingAs($user)
->test(ProjectMap::class, ['project' => $project])
->call('selectFeature', $feature->id)
->set('selectedTemplateId', $template->id)
->set('inspectionFormData.altura', '5.2')
->set('inspectionNotes', 'Todo conforme')
->set('inspectionPhotos', [UploadedFile::fake()->image('foto.jpg')])
->call('saveInspection');
$ins = Inspection::where('feature_id', $feature->id)->first();
$this->assertNotNull($ins);
$this->assertEquals('Todo conforme', $ins->notes);
$this->assertEquals('5.2', $ins->data['altura']);
$this->assertCount(1, $ins->media);
}
}