feat(issues): incidencias enriquecidas (tareas/comentarios/fotos/verificación) + tabla Rappasoft + logo

Web:
- IssueTask + IssueComment (modelos, migraciones, soft-deletes, campos de sync).
  Issue gana tasks()/comments() y accessor de % de avance derivado de tareas.
- IssueDetail (página): checklist con asignado/fecha límite/progreso, hilo de
  comentarios con foto por comentario, galería de fotos de la incidencia y flujo
  de verificación open→in_review→resolved/closed (+reabrir) con notas.
- Creación/edición en páginas propias (IssueForm), sin modal; al guardar redirige
  al detalle. Rutas projects.issues.create/edit/show.
- Listado con tabla Rappasoft (IssueTable): filtros por estado/prioridad, búsqueda,
  barra de progreso y acciones por fila gateadas por permisos; IssueManager queda
  como contenedor (cabecera + stats) que embebe la tabla.
- Seguridad: pertenencia al proyecto + permisos por acción (view/create/edit/delete
  issues, upload/delete media) en todos los componentes.

API móvil (offline):
- /sync: issue_task.create/update y issue_comment.create (idempotente, LWW).
- /media: parent_entity issue_task / issue_comment.
- bundle + tombstones incluyen issue_tasks / issue_comments.
- openapi.yaml + MOBILE_SYNC_PROTOCOL.md actualizados.

Tests: MobileApiTest 23 passing (+5); IssuesTablePageTest (3) smoke de la tabla.

Branding: logo RTE International — MAI Group (public/images/logo-rte.png) en login
y navegación; application-logo pasa de SVG por defecto a <img>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 12:12:39 +02:00
parent 14758136b6
commit 3f240e5277
25 changed files with 1604 additions and 566 deletions
@@ -0,0 +1,119 @@
<div class="max-w-2xl mx-auto">
<a href="{{ route('projects.issues', $project) }}" wire:navigate
class="btn btn-ghost btn-sm gap-1 mb-3">
<x-heroicon-o-arrow-left class="w-4 h-4" /> Volver a incidencias
</a>
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<h1 class="text-xl font-bold mb-1">
{{ $issue ? 'Editar incidencia' : 'Nueva incidencia' }}
</h1>
<p class="text-sm text-base-content/60 mb-4">{{ $project->name }}</p>
<form wire:submit.prevent="save" class="space-y-4">
{{-- Título --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Título <span class="text-error">*</span></span>
</label>
<input type="text" wire:model="title" autofocus
class="input input-bordered w-full @error('title') input-error @enderror"
placeholder="Describe brevemente el problema..." />
@error('title')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Descripción --}}
<div class="form-control">
<label class="label"><span class="label-text font-medium">Descripción</span></label>
<textarea wire:model="description"
class="textarea textarea-bordered w-full h-28 resize-y @error('description') textarea-error @enderror"
placeholder="Detalla el problema, contexto, pasos para reproducirlo..."></textarea>
@error('description')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Prioridad + Estado --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Prioridad <span class="text-error">*</span></span>
</label>
<select wire:model="priority"
class="select select-bordered w-full @error('priority') select-error @enderror">
<option value="low">Baja</option>
<option value="medium">Media</option>
<option value="high">Alta</option>
<option value="critical">Crítica</option>
</select>
@error('priority')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Estado <span class="text-error">*</span></span>
</label>
<select wire:model.live="status"
class="select select-bordered w-full @error('status') select-error @enderror">
<option value="open">Abierto</option>
<option value="in_review">En revisión</option>
<option value="resolved">Resuelto</option>
<option value="closed">Cerrado</option>
</select>
@error('status')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
</div>
{{-- Asignado a --}}
<div class="form-control">
<label class="label"><span class="label-text font-medium">Asignado a</span></label>
<select wire:model="assignedTo"
class="select select-bordered w-full @error('assignedTo') select-error @enderror">
<option value="">Sin asignar</option>
@foreach($projectUsers as $user)
<option value="{{ $user->id }}">{{ $user->name }} &ndash; {{ $user->email }}</option>
@endforeach
</select>
@error('assignedTo')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
{{-- Notas de resolución (visible when status = resolved or closed) --}}
@if(in_array($status, ['resolved', 'closed']))
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Notas de resolución</span>
<span class="label-text-alt text-base-content/50">Opcional</span>
</label>
<textarea wire:model="resolutionNotes"
class="textarea textarea-bordered w-full h-20 resize-y @error('resolutionNotes') textarea-error @enderror"
placeholder="Describe cómo se resolvió el problema..."></textarea>
@error('resolutionNotes')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
@endif
{{-- Footer --}}
<div class="flex items-center justify-end gap-3 pt-2 border-t border-base-300">
<a href="{{ route('projects.issues', $project) }}" wire:navigate class="btn btn-ghost">Cancelar</a>
<button type="submit" wire:loading.attr="disabled" wire:target="save" class="btn btn-primary gap-2">
<span wire:loading.remove wire:target="save"><x-heroicon-o-check class="w-4 h-4" /></span>
<span wire:loading wire:target="save" class="loading loading-spinner loading-sm"></span>
{{ $issue ? 'Actualizar incidencia' : 'Crear incidencia' }}
</button>
</div>
</form>
</div>
</div>
</div>