8c774d075d
- 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>
224 lines
11 KiB
PHP
224 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Models\Issue;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Str;
|
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
|
use Rappasoft\LaravelLivewireTables\Views\Filters\SelectFilter;
|
|
|
|
class IssueTable extends DataTableComponent
|
|
{
|
|
protected $model = Issue::class;
|
|
|
|
public int $projectId;
|
|
|
|
public function configure(): void
|
|
{
|
|
$this->setPrimaryKey('id')
|
|
->setDefaultSort('created_at', 'desc')
|
|
->setSortingPillsEnabled(false)
|
|
->setAdditionalSelects(['issues.id as id', 'issues.created_at as created_at']);
|
|
}
|
|
|
|
public function builder(): Builder
|
|
{
|
|
// Defence in depth: only members (or super-admin) may list a project's issues.
|
|
$user = Auth::user();
|
|
abort_unless(
|
|
$user->can('view issues') && (
|
|
$user->can('manage all')
|
|
|| \App\Models\Project::whereKey($this->projectId)->whereHas('users', fn ($q) => $q->where('user_id', $user->id))->exists()
|
|
),
|
|
403
|
|
);
|
|
|
|
return Issue::where('issues.project_id', $this->projectId)
|
|
->with(['feature', 'reporter', 'assignee'])
|
|
->withCount([
|
|
'comments',
|
|
'media',
|
|
'tasks',
|
|
'tasks as tasks_done_count' => fn (Builder $q) => $q->where('is_done', true),
|
|
'tasks as overdue_tasks_count' => fn (Builder $q) => $q->where('is_done', false)
|
|
->whereNotNull('due_date')
|
|
->whereDate('due_date', '<', now()->toDateString()),
|
|
]);
|
|
}
|
|
|
|
public function columns(): array
|
|
{
|
|
return [
|
|
Column::make('Prioridad', 'priority')
|
|
->sortable()
|
|
->format(function ($value, $row) {
|
|
$label = ['low' => 'Bajo', 'medium' => 'Medio', 'high' => 'Alto', 'critical' => 'Crítico'][$value] ?? ucfirst($value);
|
|
$textColor = in_array($value, ['critical', 'high']) ? '#fff' : '#1f2937';
|
|
return '<span class="badge badge-sm font-semibold" style="background-color:'.$row->priority_color.';color:'.$textColor.';border-color:transparent;">'.$label.'</span>';
|
|
})
|
|
->html(),
|
|
|
|
Column::make('Título', 'title')
|
|
->sortable()
|
|
->searchable()
|
|
->format(function ($value, $row) {
|
|
$url = route('projects.issues.show', [$this->projectId, $row->id]);
|
|
$html = '<a href="'.$url.'" wire:navigate class="font-medium text-sm link link-hover">'.e($value).'</a>';
|
|
if ($row->description) {
|
|
$html .= '<div class="text-xs text-base-content/50 truncate max-w-xs">'.e(Str::limit($row->description, 60)).'</div>';
|
|
}
|
|
$meta = [];
|
|
if ($row->reporter) $meta[] = 'Reportado por '.e($row->reporter->name);
|
|
if ($row->comments_count) $meta[] = '💬 '.$row->comments_count;
|
|
if ($row->media_count) $meta[] = '📷 '.$row->media_count;
|
|
if ($meta) {
|
|
$html .= '<div class="text-xs text-base-content/40 mt-0.5">'.implode(' · ', $meta).'</div>';
|
|
}
|
|
if ($row->tasks_count) {
|
|
$pct = (int) round($row->tasks_done_count / $row->tasks_count * 100);
|
|
$html .= '<div class="flex items-center gap-2 mt-1 max-w-xs">
|
|
<progress class="progress progress-success w-24 h-1.5" value="'.$pct.'" max="100"></progress>
|
|
<span class="text-xs text-base-content/50">'.$row->tasks_done_count.'/'.$row->tasks_count.' tareas</span>
|
|
</div>';
|
|
}
|
|
if ($row->overdue_tasks_count) {
|
|
$html .= '<div class="mt-1"><span class="badge badge-error badge-sm gap-1">⏰ '.$row->overdue_tasks_count.' vencida'.($row->overdue_tasks_count > 1 ? 's' : '').'</span></div>';
|
|
}
|
|
return $html;
|
|
})
|
|
->html(),
|
|
|
|
Column::make('Feature')
|
|
->label(fn ($row) => $row->feature
|
|
? '<span class="badge badge-outline badge-sm">'.e($row->feature->name).'</span>'
|
|
: '<span class="text-base-content/30 text-xs">—</span>')
|
|
->html(),
|
|
|
|
Column::make('Estado', 'status')
|
|
->sortable()
|
|
->format(function ($value, $row) {
|
|
$label = ['open' => 'Abierto', 'in_review' => 'En revisión', 'resolved' => 'Resuelto', 'closed' => 'Cerrado'][$value] ?? ucfirst($value);
|
|
return '<span class="badge badge-sm" style="background-color:'.$row->status_color.';color:#fff;border-color:transparent;">'.$label.'</span>';
|
|
})
|
|
->html(),
|
|
|
|
Column::make('Asignado a')
|
|
->label(fn ($row) => $row->assignee
|
|
? '<span class="text-sm">'.e($row->assignee->name).'</span>'
|
|
: '<span class="text-base-content/30 text-xs">Sin asignar</span>')
|
|
->html(),
|
|
|
|
Column::make('Fecha', 'created_at')
|
|
->sortable()
|
|
->format(function ($value, $row) {
|
|
$html = $row->created_at->format('d/m/Y');
|
|
if ($row->resolved_at) {
|
|
$html .= '<div class="text-success text-xs">Res. '.$row->resolved_at->format('d/m/Y').'</div>';
|
|
}
|
|
return $html;
|
|
})
|
|
->html(),
|
|
|
|
Column::make('Acciones')
|
|
->label(function ($row) {
|
|
$user = Auth::user();
|
|
$detail = route('projects.issues.show', [$this->projectId, $row->id]);
|
|
$edit = route('projects.issues.edit', [$this->projectId, $row->id]);
|
|
|
|
$html = '<div class="flex items-center justify-end gap-1 flex-wrap">';
|
|
$html .= '<a href="'.$detail.'" wire:navigate class="btn btn-xs btn-ghost" title="Abrir detalle">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
|
</a>';
|
|
|
|
if ($user->can('edit issues')) {
|
|
$html .= '<a href="'.$edit.'" wire:navigate class="btn btn-xs btn-ghost" title="Editar">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
</a>';
|
|
if (in_array($row->status, ['open', 'in_review'])) {
|
|
$html .= '<button wire:click="resolve('.$row->id.')" class="btn btn-xs btn-success" title="Marcar como resuelto">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
</button>';
|
|
}
|
|
if ($row->status !== 'closed') {
|
|
$html .= '<button wire:click="close('.$row->id.')" class="btn btn-xs btn-neutral" title="Cerrar">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>';
|
|
}
|
|
}
|
|
|
|
if ($user->can('delete issues')) {
|
|
$html .= '<button wire:click="deleteIssue('.$row->id.')" wire:confirm="¿Eliminar esta incidencia? Esta acción no se puede deshacer."
|
|
class="btn btn-xs btn-error btn-outline" title="Eliminar">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
</button>';
|
|
}
|
|
|
|
$html .= '</div>';
|
|
return $html;
|
|
})
|
|
->html(),
|
|
];
|
|
}
|
|
|
|
public function filters(): array
|
|
{
|
|
return [
|
|
SelectFilter::make('Estado', 'status')
|
|
->options([
|
|
'' => 'Estado: todos',
|
|
'open' => 'Abierto',
|
|
'in_review' => 'En revisión',
|
|
'resolved' => 'Resuelto',
|
|
'closed' => 'Cerrado',
|
|
])
|
|
->filter(fn (Builder $query, string $value) => $query->where('issues.status', $value)),
|
|
|
|
SelectFilter::make('Prioridad', 'priority')
|
|
->options([
|
|
'' => 'Prioridad: todas',
|
|
'critical' => 'Crítica',
|
|
'high' => 'Alta',
|
|
'medium' => 'Media',
|
|
'low' => 'Baja',
|
|
])
|
|
->filter(fn (Builder $query, string $value) => $query->where('issues.priority', $value)),
|
|
];
|
|
}
|
|
|
|
// ── Row actions ────────────────────────────────────────────────────────────────
|
|
|
|
private function findIssue(int $id): Issue
|
|
{
|
|
return Issue::where('project_id', $this->projectId)->findOrFail($id);
|
|
}
|
|
|
|
public function resolve(int $id): void
|
|
{
|
|
abort_unless(Auth::user()->can('edit issues'), 403);
|
|
$issue = $this->findIssue($id);
|
|
$issue->update(['status' => 'resolved', 'resolved_at' => $issue->resolved_at ?? now()]);
|
|
$this->dispatch('issuesChanged');
|
|
$this->dispatch('notify', 'Incidencia marcada como resuelta');
|
|
}
|
|
|
|
public function close(int $id): void
|
|
{
|
|
abort_unless(Auth::user()->can('edit issues'), 403);
|
|
$issue = $this->findIssue($id);
|
|
$issue->update(['status' => 'closed', 'resolved_at' => $issue->resolved_at ?? now()]);
|
|
$this->dispatch('issuesChanged');
|
|
$this->dispatch('notify', 'Incidencia cerrada');
|
|
}
|
|
|
|
public function deleteIssue(int $id): void
|
|
{
|
|
abort_unless(Auth::user()->can('delete issues'), 403);
|
|
$this->findIssue($id)->delete();
|
|
$this->dispatch('issuesChanged');
|
|
$this->dispatch('notify', 'Incidencia eliminada');
|
|
}
|
|
}
|