Files
construprogress/resources/views/livewire/issues/issue-detail.blade.php
T
javier 8c774d075d feat(issues): notificaciones, plantillas de checklist, alertas de vencimiento y reporte desde el mapa
- Notificaciones (DB): asignación de incidencia (IssueAssigned), asignación de tarea
  (IssueTaskAssigned), comentario (IssueCommented) y cambio de estado
  (IssueStatusChanged) a reporter+asignado excluyendo al actor.
- Plantillas de checklist: tabla issue_checklist_templates + modelo, gestor CRUD
  (IssueChecklistManager, ruta projects.issues.checklists) y "Aplicar plantilla" en
  el detalle (alta masiva de tareas).
- Alertas de vencimiento: columna overdue_notified_at + scope overdue, comando
  issues:notify-overdue (programado a diario) que avisa al asignado una sola vez;
  badge "vencidas" en la tabla y resaltado por tarea en el detalle.
- Reporte desde el mapa: botón "Incidencia" en el panel del feature seleccionado →
  formulario con feature pre-vinculado (IssueForm lee ?feature=).

Tests: IssuesEnhancementsTest (7). Suite 57 passing (solo 2 pre-existentes sqlite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:51:41 +02:00

319 lines
18 KiB
PHP

<div class="max-w-5xl mx-auto">
{{-- ================================================================
BACK + HEADER
================================================================ --}}
@php
$statusLabel = [
'open' => 'Abierto', 'in_review' => 'En revisión',
'resolved' => 'Resuelto', 'closed' => 'Cerrado',
][$issue->status] ?? $issue->status;
$priorityLabel = [
'low' => 'Baja', 'medium' => 'Media', 'high' => 'Alta', 'critical' => 'Crítica',
][$issue->priority] ?? $issue->priority;
$doneCount = $issue->tasks->where('is_done', true)->count();
$totalCount = $issue->tasks->count();
@endphp
<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="flex flex-wrap items-start justify-between gap-3 mb-4">
<div class="min-w-0">
<h1 class="text-2xl font-bold leading-tight">{{ $issue->title }}</h1>
<div class="flex flex-wrap items-center gap-2 mt-2">
<span class="badge badge-sm" style="background-color: {{ $issue->status_color }}; color:#fff; border:0;">{{ $statusLabel }}</span>
<span class="badge badge-sm" style="background-color: {{ $issue->priority_color }}; color:#fff; border:0;">Prioridad: {{ $priorityLabel }}</span>
@if($issue->feature)
<span class="badge badge-outline badge-sm">{{ $issue->feature->name }}</span>
@endif
</div>
<div class="text-xs text-base-content/50 mt-2">
Reportado por <strong>{{ $issue->reporter?->name ?? '—' }}</strong>
el {{ $issue->created_at->format('d/m/Y H:i') }}
@if($issue->assignee) · Asignado a <strong>{{ $issue->assignee->name }}</strong> @endif
</div>
</div>
{{-- Resolution progress --}}
@if($totalCount > 0)
<div class="text-right">
<div class="radial-progress text-success" style="--value:{{ $issue->progress }}; --size:4rem;" role="progressbar">
{{ $issue->progress }}%
</div>
<div class="text-xs text-base-content/50 mt-1">{{ $doneCount }}/{{ $totalCount }} tareas</div>
</div>
@endif
</div>
@if($issue->description)
<div class="bg-base-200 rounded-box p-4 mb-4 text-sm whitespace-pre-line">{{ $issue->description }}</div>
@endif
{{-- ================================================================
STATUS WORKFLOW BAR
================================================================ --}}
@if($canEdit)
<div class="flex flex-wrap items-center gap-2 mb-6 p-3 bg-base-100 border border-base-300 rounded-box">
<span class="text-sm font-medium text-base-content/70 mr-1">Acciones:</span>
@if(in_array($issue->status, ['open']))
<button wire:click="sendToReview" class="btn btn-sm btn-warning gap-1"
@if($totalCount > 0 && $doneCount < $totalCount) disabled @endif>
<x-heroicon-o-eye class="w-4 h-4" /> Enviar a revisión
</button>
@if($totalCount > 0 && $doneCount < $totalCount)
<span class="text-xs text-base-content/50">Completa todas las tareas para enviar a revisión</span>
@endif
@endif
@if(in_array($issue->status, ['open', 'in_review']))
<button wire:click="verifyResolve" class="btn btn-sm btn-success gap-1">
<x-heroicon-o-check-badge class="w-4 h-4" /> Validar y resolver
</button>
@endif
@if($issue->status !== 'closed')
<button wire:click="closeIssue" class="btn btn-sm btn-neutral gap-1">
<x-heroicon-o-x-mark class="w-4 h-4" /> Cerrar
</button>
@endif
@if(in_array($issue->status, ['resolved', 'closed']))
<button wire:click="reopen" class="btn btn-sm btn-outline gap-1">
<x-heroicon-o-arrow-path class="w-4 h-4" /> Reabrir
</button>
@endif
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- ================================================================
LEFT: CHECKLIST + COMMENTS
================================================================ --}}
<div class="lg:col-span-2 space-y-6">
{{-- CHECKLIST --}}
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="font-bold flex items-center gap-2">
<x-heroicon-o-clipboard-document-check class="w-5 h-5" />
Tareas para resolver
<span class="badge badge-sm">{{ $doneCount }}/{{ $totalCount }}</span>
</h2>
@if($totalCount > 0)
<progress class="progress progress-success w-full h-2 my-1" value="{{ $issue->progress }}" max="100"></progress>
@endif
<ul class="divide-y divide-base-200">
@forelse($issue->tasks as $task)
<li wire:key="task-{{ $task->id }}" class="flex items-center gap-3 py-2">
<input type="checkbox"
class="checkbox checkbox-sm checkbox-success"
@checked($task->is_done)
@disabled(! $canEdit)
wire:click="toggleTask({{ $task->id }})" />
<div class="flex-1 min-w-0">
<div class="text-sm {{ $task->is_done ? 'line-through text-base-content/40' : '' }}">{{ $task->title }}</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-base-content/50">
@if($task->assignee)<span>👤 {{ $task->assignee->name }}</span>@endif
@if($task->due_date)
<span class="{{ $task->is_overdue ? 'text-error font-semibold' : '' }}">📅 {{ $task->due_date->format('d/m/Y') }}</span>
@endif
@if($task->is_done && $task->completer)
<span class="text-success"> {{ $task->completer->name }} · {{ $task->done_at?->format('d/m/Y') }}</span>
@endif
</div>
</div>
@if($canEdit)
<button wire:click="deleteTask({{ $task->id }})"
wire:confirm="¿Eliminar esta tarea?"
class="btn btn-xs btn-ghost text-error">
<x-heroicon-o-trash class="w-3.5 h-3.5" />
</button>
@endif
</li>
@empty
<li class="py-3 text-sm text-base-content/40">Aún no hay tareas. Añade la primera abajo.</li>
@endforelse
</ul>
@if($canEdit)
<form wire:submit.prevent="addTask" class="mt-3 grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div class="sm:col-span-6">
<input type="text" wire:model="newTaskTitle"
class="input input-bordered input-sm w-full @error('newTaskTitle') input-error @enderror"
placeholder="Nueva tarea..." />
@error('newTaskTitle')<span class="text-xs text-error">{{ $message }}</span>@enderror
</div>
<select wire:model="newTaskAssignee" class="select select-bordered select-sm sm:col-span-3">
<option value="">Sin asignar</option>
@foreach($projectUsers as $u)
<option value="{{ $u->id }}">{{ $u->name }}</option>
@endforeach
</select>
<input type="date" wire:model="newTaskDue" class="input input-bordered input-sm sm:col-span-2" />
<button type="submit" class="btn btn-sm btn-primary sm:col-span-1">
<x-heroicon-o-plus class="w-4 h-4" />
</button>
</form>
@if(count($checklistTemplates))
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-base-200">
<span class="text-xs text-base-content/50">Aplicar plantilla:</span>
<select wire:model="applyTemplateId" class="select select-bordered select-xs">
<option value="">Elegir...</option>
@foreach($checklistTemplates as $tpl)
<option value="{{ $tpl->id }}">{{ $tpl->name }} ({{ count($tpl->items ?: []) }})</option>
@endforeach
</select>
<button wire:click="applyTemplate" class="btn btn-xs btn-outline" @disabled(!$applyTemplateId)>Aplicar</button>
</div>
@error('applyTemplateId')<span class="text-xs text-error">{{ $message }}</span>@enderror
@endif
@endif
</div>
</div>
{{-- COMMENTS / SEGUIMIENTO --}}
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="font-bold flex items-center gap-2">
<x-heroicon-o-chat-bubble-left-right class="w-5 h-5" />
Seguimiento y comentarios
<span class="badge badge-sm">{{ $issue->comments->count() }}</span>
</h2>
<div class="space-y-4 mt-2">
@forelse($issue->comments as $comment)
<div wire:key="comment-{{ $comment->id }}" class="flex gap-3">
<span class="w-8 h-8 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold flex-shrink-0">
{{ strtoupper(substr($comment->user?->name ?? '?', 0, 1)) }}
</span>
<div class="flex-1 min-w-0">
<div class="text-sm">
<strong>{{ $comment->user?->name ?? 'Usuario' }}</strong>
<span class="text-xs text-base-content/40">· {{ $comment->created_at->diffForHumans() }}</span>
</div>
<div class="text-sm whitespace-pre-line">{{ $comment->body }}</div>
@if($comment->media->count())
<div class="flex flex-wrap gap-2 mt-2">
@foreach($comment->media as $m)
<a href="{{ $m->url }}" target="_blank" wire:key="cmedia-{{ $m->id }}">
<img src="{{ $m->url }}" class="w-20 h-20 object-cover rounded-box border border-base-300" />
</a>
@endforeach
</div>
@endif
</div>
</div>
@empty
<p class="text-sm text-base-content/40">Sin comentarios todavía.</p>
@endforelse
</div>
{{-- New comment --}}
<form wire:submit.prevent="addComment" class="mt-4 border-t border-base-200 pt-3 space-y-2">
<textarea wire:model="newComment"
class="textarea textarea-bordered w-full h-20 @error('newComment') textarea-error @enderror"
placeholder="Escribe un comentario o nota de seguimiento..."></textarea>
@error('newComment')<span class="text-xs text-error">{{ $message }}</span>@enderror
<div class="flex items-center justify-between gap-2 flex-wrap">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<x-heroicon-o-paper-clip class="w-4 h-4" />
<input type="file" wire:model="commentPhoto" accept="image/*"
class="file-input file-input-bordered file-input-xs max-w-xs" />
</label>
<button type="submit" class="btn btn-sm btn-primary gap-1"
wire:loading.attr="disabled" wire:target="addComment,commentPhoto">
<span wire:loading.remove wire:target="commentPhoto"><x-heroicon-o-paper-airplane class="w-4 h-4" /></span>
<span wire:loading wire:target="commentPhoto" class="loading loading-spinner loading-xs"></span>
Comentar
</button>
</div>
@error('commentPhoto')<span class="text-xs text-error">{{ $message }}</span>@enderror
</form>
</div>
</div>
</div>
{{-- ================================================================
RIGHT: PHOTOS + RESOLUTION
================================================================ --}}
<div class="space-y-6">
{{-- ISSUE PHOTOS --}}
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="font-bold flex items-center gap-2">
<x-heroicon-o-photo class="w-5 h-5" /> Fotos de la incidencia
</h2>
@if($issue->media->count())
<div class="grid grid-cols-2 gap-2 mt-2">
@foreach($issue->media as $m)
<div wire:key="imedia-{{ $m->id }}" class="relative group">
<a href="{{ $m->url }}" target="_blank">
<img src="{{ $m->url }}" class="w-full h-24 object-cover rounded-box border border-base-300" />
</a>
@can('delete media')
<button wire:click="deleteMedia({{ $m->id }})" wire:confirm="¿Eliminar foto?"
class="btn btn-xs btn-error btn-circle absolute top-1 right-1 opacity-0 group-hover:opacity-100">
<x-heroicon-o-x-mark class="w-3 h-3" />
</button>
@endcan
</div>
@endforeach
</div>
@else
<p class="text-sm text-base-content/40 mt-1">Sin fotos.</p>
@endif
@can('upload media')
<form wire:submit.prevent="uploadIssuePhotos" class="mt-3 space-y-2">
<input type="file" wire:model="issuePhotos" multiple accept="image/*"
class="file-input file-input-bordered file-input-sm w-full" />
@error('issuePhotos.*')<span class="text-xs text-error">{{ $message }}</span>@enderror
@if($issuePhotos)
<button type="submit" class="btn btn-sm btn-primary w-full gap-1"
wire:loading.attr="disabled" wire:target="uploadIssuePhotos,issuePhotos">
<span wire:loading wire:target="issuePhotos" class="loading loading-spinner loading-xs"></span>
Subir {{ count($issuePhotos) }} foto(s)
</button>
@endif
</form>
@endcan
</div>
</div>
{{-- RESOLUTION NOTES --}}
@if($canEdit)
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="font-bold flex items-center gap-2">
<x-heroicon-o-clipboard-document-list class="w-5 h-5" /> Notas de resolución
</h2>
<textarea wire:model="resolutionNotes"
class="textarea textarea-bordered w-full h-24"
placeholder="Cómo se resolvió la incidencia..."></textarea>
<button wire:click="verifyResolve" class="btn btn-sm btn-success gap-1 mt-1">
<x-heroicon-o-check-badge class="w-4 h-4" /> Guardar y resolver
</button>
</div>
</div>
@elseif($issue->resolution_notes)
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<h2 class="font-bold">Notas de resolución</h2>
<p class="text-sm whitespace-pre-line">{{ $issue->resolution_notes }}</p>
</div>
</div>
@endif
</div>
</div>
</div>