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:
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user