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
@@ -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>