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:
@@ -0,0 +1,304 @@
|
||||
<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>
|
||||
@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>
|
||||
@@ -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 }} – {{ $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>
|
||||
@@ -4,28 +4,24 @@
|
||||
================================================================ --}}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">Issues del proyecto</h2>
|
||||
<h2 class="text-xl font-bold">Incidencias del proyecto</h2>
|
||||
<p class="text-sm text-base-content/60">Gestión de incidencias y problemas</p>
|
||||
</div>
|
||||
<button
|
||||
wire:click="openForm()"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
>
|
||||
<x-heroicon-o-plus class="w-4 h-4" />
|
||||
Nuevo Issue
|
||||
</button>
|
||||
@can('create issues')
|
||||
<a
|
||||
href="{{ route('projects.issues.create', $project) }}"
|
||||
wire:navigate
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
>
|
||||
<x-heroicon-o-plus class="w-4 h-4" />
|
||||
Nueva incidencia
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
{{-- ================================================================
|
||||
STATS BAR
|
||||
================================================================ --}}
|
||||
@php
|
||||
$countOpen = $issues->where('status', 'open')->count();
|
||||
$countInReview = $issues->where('status', 'in_review')->count();
|
||||
$countResolved = $issues->where('status', 'resolved')->count();
|
||||
$countClosed = $issues->where('status', 'closed')->count();
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-5">
|
||||
<div class="stat bg-base-200 rounded-box py-2 px-4 flex-1 min-w-[120px]">
|
||||
<div class="stat-title text-xs">Abiertos</div>
|
||||
@@ -45,359 +41,12 @@
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-box py-2 px-4 flex-1 min-w-[120px]">
|
||||
<div class="stat-title text-xs">Total</div>
|
||||
<div class="stat-value text-2xl">{{ $issues->count() }}</div>
|
||||
<div class="stat-value text-2xl">{{ $countTotal }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ================================================================
|
||||
ISSUES TABLE
|
||||
ISSUES TABLE (Rappasoft)
|
||||
================================================================ --}}
|
||||
@if($issues->isEmpty())
|
||||
<div class="flex flex-col items-center justify-center py-16 text-base-content/40">
|
||||
<x-heroicon-o-bug-ant class="w-16 h-16 mb-3" />
|
||||
<p class="text-lg font-semibold">Sin issues registrados</p>
|
||||
<p class="text-sm">Crea el primer issue con el botón "Nuevo Issue".</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||
<table class="table table-sm w-full">
|
||||
<thead class="bg-base-200">
|
||||
<tr>
|
||||
<th class="w-28">Prioridad</th>
|
||||
<th>Título</th>
|
||||
<th class="hidden md:table-cell">Feature</th>
|
||||
<th class="w-28">Estado</th>
|
||||
<th class="hidden lg:table-cell w-36">Asignado a</th>
|
||||
<th class="hidden lg:table-cell w-28">Fecha</th>
|
||||
<th class="w-36 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($issues as $issue)
|
||||
<tr wire:key="issue-{{ $issue->id }}" class="hover">
|
||||
{{-- Prioridad --}}
|
||||
<td>
|
||||
@php
|
||||
$pClass = match($issue->priority) {
|
||||
'critical' => 'badge-purple',
|
||||
'high' => 'badge-error',
|
||||
'medium' => 'badge-warning',
|
||||
'low' => 'badge-ghost',
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
$pLabel = match($issue->priority) {
|
||||
'critical' => 'Crítico',
|
||||
'high' => 'Alto',
|
||||
'medium' => 'Medio',
|
||||
'low' => 'Bajo',
|
||||
default => ucfirst($issue->priority),
|
||||
};
|
||||
@endphp
|
||||
<span
|
||||
class="badge badge-sm font-semibold
|
||||
{{ $issue->priority === 'critical' ? 'text-white' : '' }}"
|
||||
style="background-color: {{ $issue->priority_color }}; color: {{ $issue->priority === 'critical' || $issue->priority === 'high' ? '#fff' : '#1f2937' }}; border-color: transparent;"
|
||||
>
|
||||
{{ $pLabel }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{{-- Título + descripción breve --}}
|
||||
<td>
|
||||
<div class="font-medium text-sm leading-tight">{{ $issue->title }}</div>
|
||||
@if($issue->description)
|
||||
<div class="text-xs text-base-content/50 truncate max-w-xs">{{ Str::limit($issue->description, 60) }}</div>
|
||||
@endif
|
||||
@if($issue->reporter)
|
||||
<div class="text-xs text-base-content/40 mt-0.5">
|
||||
Reportado por {{ $issue->reporter->name }}
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- Feature --}}
|
||||
<td class="hidden md:table-cell">
|
||||
@if($issue->feature)
|
||||
<span class="badge badge-outline badge-sm">{{ $issue->feature->name }}</span>
|
||||
@else
|
||||
<span class="text-base-content/30 text-xs">—</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- Estado --}}
|
||||
<td>
|
||||
@php
|
||||
$sLabel = match($issue->status) {
|
||||
'open' => 'Abierto',
|
||||
'in_review' => 'En revisión',
|
||||
'resolved' => 'Resuelto',
|
||||
'closed' => 'Cerrado',
|
||||
default => ucfirst($issue->status),
|
||||
};
|
||||
@endphp
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
style="background-color: {{ $issue->status_color }}; color: #fff; border-color: transparent;"
|
||||
>
|
||||
{{ $sLabel }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{{-- Asignado a --}}
|
||||
<td class="hidden lg:table-cell">
|
||||
@if($issue->assignee)
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-6 h-6 rounded-full bg-primary text-primary-content flex items-center justify-center text-xs font-bold flex-shrink-0">
|
||||
{{ strtoupper(substr($issue->assignee->name, 0, 1)) }}
|
||||
</span>
|
||||
<span class="text-sm truncate">{{ $issue->assignee->name }}</span>
|
||||
</div>
|
||||
@else
|
||||
<span class="text-base-content/30 text-xs">Sin asignar</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- Fecha --}}
|
||||
<td class="hidden lg:table-cell text-xs text-base-content/50">
|
||||
{{ $issue->created_at->format('d/m/Y') }}
|
||||
@if($issue->resolved_at)
|
||||
<div class="text-success">Res. {{ $issue->resolved_at->format('d/m/Y') }}</div>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- Acciones --}}
|
||||
<td class="text-right">
|
||||
<div class="flex items-center justify-end gap-1 flex-wrap">
|
||||
{{-- Editar --}}
|
||||
<button
|
||||
wire:click="openForm({{ $issue->id }})"
|
||||
class="btn btn-xs btn-ghost tooltip"
|
||||
data-tip="Editar"
|
||||
>
|
||||
<x-heroicon-o-pencil class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{{-- Resolver --}}
|
||||
@if(in_array($issue->status, ['open', 'in_review']))
|
||||
<button
|
||||
wire:click="resolve({{ $issue->id }})"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="resolve({{ $issue->id }})"
|
||||
class="btn btn-xs btn-success tooltip"
|
||||
data-tip="Marcar como resuelto"
|
||||
>
|
||||
<x-heroicon-o-check class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Cerrar --}}
|
||||
@if($issue->status !== 'closed')
|
||||
<button
|
||||
wire:click="close({{ $issue->id }})"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="close({{ $issue->id }})"
|
||||
class="btn btn-xs btn-neutral tooltip"
|
||||
data-tip="Cerrar issue"
|
||||
>
|
||||
<x-heroicon-o-x-mark class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Eliminar --}}
|
||||
<button
|
||||
wire:click="delete({{ $issue->id }})"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="delete({{ $issue->id }})"
|
||||
wire:confirm="¿Eliminar este issue? Esta acción no se puede deshacer."
|
||||
class="btn btn-xs btn-error btn-outline tooltip"
|
||||
data-tip="Eliminar"
|
||||
>
|
||||
<x-heroicon-o-trash class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ================================================================
|
||||
MODAL FORM (create / edit)
|
||||
================================================================ --}}
|
||||
@if($showForm)
|
||||
{{-- Overlay --}}
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/50"
|
||||
wire:click="closeForm()"
|
||||
></div>
|
||||
|
||||
{{-- Modal panel --}}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="bg-base-100 rounded-box shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
{{-- Modal header --}}
|
||||
<div class="flex items-center justify-between p-5 border-b border-base-300">
|
||||
<h3 class="text-lg font-bold">
|
||||
{{ $editingIssue ? 'Editar Issue' : 'Nuevo Issue' }}
|
||||
</h3>
|
||||
<button wire:click="closeForm()" class="btn btn-sm btn-ghost btn-circle">
|
||||
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Modal body --}}
|
||||
<form wire:submit.prevent="save" class="p-5 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"
|
||||
class="input input-bordered w-full @error('title') input-error @enderror"
|
||||
placeholder="Describe brevemente el problema..."
|
||||
autofocus
|
||||
/>
|
||||
@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-24 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 }} – {{ $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
|
||||
|
||||
{{-- Modal footer --}}
|
||||
<div class="flex items-center justify-end gap-3 pt-2 border-t border-base-300">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="closeForm()"
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<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>
|
||||
{{ $editingIssue ? 'Actualizar Issue' : 'Crear Issue' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<livewire:issue-table :project-id="$project->id" :key="'issue-table-'.$project->id" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user