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>
This commit is contained in:
2026-06-18 12:51:41 +02:00
parent 3f240e5277
commit 8c774d075d
22 changed files with 818 additions and 15 deletions
@@ -0,0 +1,98 @@
<div class="max-w-3xl 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="flex flex-wrap items-center justify-between gap-3 mb-5">
<div>
<h1 class="text-xl font-bold">Plantillas de checklist</h1>
<p class="text-sm text-base-content/60">Listas de tareas reutilizables para incidencias recurrentes · {{ $project->name }}</p>
</div>
<button wire:click="newTemplate" class="btn btn-primary btn-sm gap-2">
<x-heroicon-o-plus class="w-4 h-4" /> Nueva plantilla
</button>
</div>
{{-- List --}}
@if($templates->isEmpty())
<div class="flex flex-col items-center justify-center py-12 text-base-content/40">
<x-heroicon-o-clipboard-document-check class="w-14 h-14 mb-3" />
<p class="font-semibold">Sin plantillas</p>
<p class="text-sm">Crea una lista de tareas reutilizable para aplicarla a tus incidencias.</p>
</div>
@else
<div class="space-y-2">
@foreach($templates as $t)
<div wire:key="tpl-{{ $t->id }}" class="card bg-base-100 border border-base-300">
<div class="card-body p-4 flex-row items-center justify-between gap-3">
<div class="min-w-0">
<div class="font-semibold">{{ $t->name }}</div>
<div class="text-xs text-base-content/50">{{ count($t->items ?: []) }} tareas: {{ Str::limit(implode(' · ', $t->items ?: []), 90) }}</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<button wire:click="edit({{ $t->id }})" class="btn btn-xs btn-ghost" title="Editar">
<x-heroicon-o-pencil class="w-3.5 h-3.5" />
</button>
<button wire:click="delete({{ $t->id }})" wire:confirm="¿Eliminar la plantilla '{{ $t->name }}'?"
class="btn btn-xs btn-error btn-outline" title="Eliminar">
<x-heroicon-o-trash class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
@endforeach
</div>
@endif
{{-- Form modal --}}
@if($showForm)
<div class="fixed inset-0 z-40 bg-black/50" wire:click="$set('showForm', false)"></div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-base-100 rounded-box shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between p-4 border-b border-base-300">
<h3 class="font-bold">{{ $editingId ? 'Editar plantilla' : 'Nueva plantilla' }}</h3>
<button wire:click="$set('showForm', false)" class="btn btn-sm btn-ghost btn-circle">
<x-heroicon-o-x-mark class="w-4 h-4" />
</button>
</div>
<form wire:submit.prevent="save" class="p-4 space-y-3">
<div class="form-control">
<label class="label"><span class="label-text font-medium">Nombre <span class="text-error">*</span></span></label>
<input type="text" wire:model="name"
class="input input-bordered w-full @error('name') input-error @enderror"
placeholder="Ej.: Reparación de grieta" />
@error('name')<span class="text-xs text-error">{{ $message }}</span>@enderror
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Tareas</span></label>
<div class="space-y-2">
@foreach($items as $i => $item)
<div wire:key="item-{{ $i }}" class="flex items-center gap-2">
<input type="text" wire:model="items.{{ $i }}"
class="input input-bordered input-sm w-full"
placeholder="Descripción de la tarea..." />
<button type="button" wire:click="removeItemLine({{ $i }})"
class="btn btn-sm btn-ghost text-error">
<x-heroicon-o-x-mark class="w-4 h-4" />
</button>
</div>
@endforeach
</div>
@error('items')<span class="text-xs text-error">{{ $message }}</span>@enderror
<button type="button" wire:click="addItemLine" class="btn btn-xs btn-ghost gap-1 mt-2 w-fit">
<x-heroicon-o-plus class="w-3.5 h-3.5" /> Añadir tarea
</button>
</div>
<div class="flex justify-end gap-2 pt-2 border-t border-base-300">
<button type="button" wire:click="$set('showForm', false)" class="btn btn-ghost btn-sm">Cancelar</button>
<button type="submit" class="btn btn-primary btn-sm">Guardar</button>
</div>
</form>
</div>
</div>
@endif
</div>
@@ -161,6 +161,20 @@
<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>
@@ -12,6 +12,13 @@
</h1>
<p class="text-sm text-base-content/60 mb-4">{{ $project->name }}</p>
@if($featureName)
<div class="alert alert-info py-2 mb-4 text-sm">
<x-heroicon-o-map-pin class="w-4 h-4" />
<span>Vinculada al elemento del mapa: <strong>{{ $featureName }}</strong></span>
</div>
@endif
<form wire:submit.prevent="save" class="space-y-4">
{{-- Título --}}
@@ -7,16 +7,28 @@
<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>
@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 class="flex items-center gap-2">
@can('edit issues')
<a
href="{{ route('projects.issues.checklists', $project) }}"
wire:navigate
class="btn btn-ghost btn-sm gap-2"
>
<x-heroicon-o-clipboard-document-check class="w-4 h-4" />
Plantillas
</a>
@endcan
@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>
</div>
{{-- ================================================================