From 8c774d075dda42fae920cc781c8d92d2e85d97a6 Mon Sep 17 00:00:00 2001 From: javier Date: Thu, 18 Jun 2026 12:51:41 +0200 Subject: [PATCH] feat(issues): notificaciones, plantillas de checklist, alertas de vencimiento y reporte desde el mapa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../Commands/NotifyOverdueIssueTasks.php | 36 ++++ app/Livewire/IssueChecklistManager.php | 109 ++++++++++ app/Livewire/IssueDetail.php | 57 ++++- app/Livewire/IssueForm.php | 16 ++ app/Livewire/IssueTable.php | 6 + app/Models/IssueChecklistTemplate.php | 20 ++ app/Models/IssueTask.php | 17 +- .../IssueAssignedNotification.php | 30 +++ .../IssueCommentedNotification.php | 33 +++ .../IssueStatusChangedNotification.php | 37 ++++ .../IssueTaskAssignedNotification.php | 31 +++ .../IssueTaskOverdueNotification.php | 31 +++ ...create_issue_checklist_templates_table.php | 25 +++ ...erdue_notified_at_to_issue_tasks_table.php | 22 ++ .../issues/issue-checklist-manager.blade.php | 98 +++++++++ .../livewire/issues/issue-detail.blade.php | 14 ++ .../livewire/issues/issue-form.blade.php | 7 + .../livewire/issues/issue-manager.blade.php | 32 ++- .../livewire/projects/project-map.blade.php | 8 + routes/console.php | 4 + routes/web.php | 1 + tests/Feature/IssuesEnhancementsTest.php | 199 ++++++++++++++++++ 22 files changed, 818 insertions(+), 15 deletions(-) create mode 100644 app/Console/Commands/NotifyOverdueIssueTasks.php create mode 100644 app/Livewire/IssueChecklistManager.php create mode 100644 app/Models/IssueChecklistTemplate.php create mode 100644 app/Notifications/IssueAssignedNotification.php create mode 100644 app/Notifications/IssueCommentedNotification.php create mode 100644 app/Notifications/IssueStatusChangedNotification.php create mode 100644 app/Notifications/IssueTaskAssignedNotification.php create mode 100644 app/Notifications/IssueTaskOverdueNotification.php create mode 100644 database/migrations/2026_06_18_130000_create_issue_checklist_templates_table.php create mode 100644 database/migrations/2026_06_18_130100_add_overdue_notified_at_to_issue_tasks_table.php create mode 100644 resources/views/livewire/issues/issue-checklist-manager.blade.php create mode 100644 tests/Feature/IssuesEnhancementsTest.php diff --git a/app/Console/Commands/NotifyOverdueIssueTasks.php b/app/Console/Commands/NotifyOverdueIssueTasks.php new file mode 100644 index 0000000..00fd01e --- /dev/null +++ b/app/Console/Commands/NotifyOverdueIssueTasks.php @@ -0,0 +1,36 @@ +whereNull('overdue_notified_at') + ->whereNotNull('assigned_to') + ->with(['assignee', 'issue']) + ->get(); + + $sent = 0; + foreach ($tasks as $task) { + if ($task->assignee) { + $task->assignee->notify(new IssueTaskOverdueNotification($task)); + $sent++; + } + $task->forceFill(['overdue_notified_at' => now()])->save(); + } + + $this->info("Tareas vencidas notificadas: {$sent}"); + + return self::SUCCESS; + } +} diff --git a/app/Livewire/IssueChecklistManager.php b/app/Livewire/IssueChecklistManager.php new file mode 100644 index 0000000..8484742 --- /dev/null +++ b/app/Livewire/IssueChecklistManager.php @@ -0,0 +1,109 @@ +project = $project; + abort_unless($this->canAccessProject() && Auth::user()->can('edit issues'), 403); + $this->loadTemplates(); + } + + private function canAccessProject(): bool + { + $user = Auth::user(); + return $user->can('manage all') + || $this->project->users()->where('user_id', $user->id)->exists(); + } + + public function loadTemplates(): void + { + $this->templates = IssueChecklistTemplate::where('project_id', $this->project->id) + ->orderBy('name')->get(); + } + + public function newTemplate(): void + { + $this->reset(['editingId', 'name']); + $this->items = ['']; + $this->resetErrorBag(); + $this->showForm = true; + } + + public function edit($id): void + { + $t = IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id); + $this->editingId = $t->id; + $this->name = $t->name; + $this->items = array_values($t->items ?: ['']) ?: ['']; + $this->resetErrorBag(); + $this->showForm = true; + } + + public function addItemLine(): void + { + $this->items[] = ''; + } + + public function removeItemLine(int $i): void + { + unset($this->items[$i]); + $this->items = array_values($this->items); + if (empty($this->items)) { + $this->items = ['']; + } + } + + public function save(): void + { + $this->validate([ + 'name' => 'required|string|max:255', + 'items' => 'required|array', + 'items.*' => 'nullable|string|max:255', + ]); + + $items = array_values(array_filter(array_map('trim', $this->items), fn ($v) => $v !== '')); + if (empty($items)) { + $this->addError('items', 'Añade al menos una tarea.'); + return; + } + + IssueChecklistTemplate::updateOrCreate( + ['id' => $this->editingId, 'project_id' => $this->project->id], + ['name' => $this->name, 'items' => $items, 'project_id' => $this->project->id], + ); + + $this->showForm = false; + $this->loadTemplates(); + $this->dispatch('notify', 'Plantilla guardada'); + } + + public function delete($id): void + { + IssueChecklistTemplate::where('project_id', $this->project->id)->findOrFail($id)->delete(); + $this->loadTemplates(); + $this->dispatch('notify', 'Plantilla eliminada'); + } + + public function render() + { + return view('livewire.issues.issue-checklist-manager'); + } +} diff --git a/app/Livewire/IssueDetail.php b/app/Livewire/IssueDetail.php index 08b38ca..139d12f 100644 --- a/app/Livewire/IssueDetail.php +++ b/app/Livewire/IssueDetail.php @@ -3,9 +3,13 @@ namespace App\Livewire; use App\Models\Issue; +use App\Models\IssueChecklistTemplate; use App\Models\IssueComment; use App\Models\IssueTask; use App\Models\Project; +use App\Notifications\IssueCommentedNotification; +use App\Notifications\IssueStatusChangedNotification; +use App\Notifications\IssueTaskAssignedNotification; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; @@ -25,6 +29,10 @@ class IssueDetail extends Component public $newTaskAssignee = ''; public $newTaskDue = ''; + // Checklist templates + public $checklistTemplates = []; + public $applyTemplateId = ''; + // New comment form public string $newComment = ''; public $commentPhoto = null; // single optional photo on a comment @@ -46,6 +54,7 @@ class IssueDetail extends Component $this->resolutionNotes = $issue->resolution_notes ?? ''; $this->projectUsers = $project->users()->orderBy('name')->get(); + $this->checklistTemplates = IssueChecklistTemplate::where('project_id', $project->id)->orderBy('name')->get(); $this->refreshIssue(); } @@ -61,6 +70,17 @@ class IssueDetail extends Component return Auth::user()->can('edit issues'); } + /** Notify the issue's stakeholders (reporter + assignee), excluding the current actor. */ + private function notifyStakeholders($notification): void + { + $this->issue->loadMissing(['reporter', 'assignee']); + collect([$this->issue->reporter, $this->issue->assignee]) + ->filter() + ->unique('id') + ->reject(fn ($u) => $u->id === Auth::id()) + ->each(fn ($u) => $u->notify($notification)); + } + public function refreshIssue(): void { $this->issue->load([ @@ -82,7 +102,7 @@ class IssueDetail extends Component 'newTaskDue' => 'nullable|date', ]); - $this->issue->tasks()->create([ + $task = $this->issue->tasks()->create([ 'title' => $this->newTaskTitle, 'assigned_to' => $this->newTaskAssignee ?: null, 'due_date' => $this->newTaskDue ?: null, @@ -90,11 +110,39 @@ class IssueDetail extends Component 'uuid' => (string) \Illuminate\Support\Str::uuid(), ]); + // Notify the assignee (unless they assigned it to themselves). + if ($task->assigned_to && $task->assigned_to !== Auth::id()) { + $task->loadMissing('issue'); + $task->assignee?->notify(new IssueTaskAssignedNotification($task)); + } + $this->reset(['newTaskTitle', 'newTaskAssignee', 'newTaskDue']); $this->refreshIssue(); $this->dispatch('notify', 'Tarea añadida'); } + public function applyTemplate(): void + { + abort_unless($this->canEdit(), 403); + $this->validate(['applyTemplateId' => 'required|exists:issue_checklist_templates,id']); + + $template = IssueChecklistTemplate::where('project_id', $this->project->id) + ->findOrFail($this->applyTemplateId); + + $order = (int) $this->issue->tasks()->max('order'); + foreach ($template->items ?: [] as $title) { + $this->issue->tasks()->create([ + 'title' => $title, + 'order' => ++$order, + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + ]); + } + + $this->applyTemplateId = ''; + $this->refreshIssue(); + $this->dispatch('notify', 'Plantilla aplicada'); + } + public function toggleTask($taskId): void { abort_unless($this->canEdit(), 403); @@ -143,6 +191,9 @@ class IssueDetail extends Component $this->storeUpload($this->commentPhoto, $comment, 'comment'); } + $comment->setRelation('issue', $this->issue)->setRelation('user', Auth::user()); + $this->notifyStakeholders(new IssueCommentedNotification($comment)); + $this->reset(['newComment', 'commentPhoto']); $this->refreshIssue(); $this->dispatch('notify', 'Comentario añadido'); @@ -196,6 +247,7 @@ class IssueDetail extends Component { abort_unless($this->canEdit(), 403); $this->issue->update(['status' => 'in_review']); + $this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'in_review')); $this->refreshIssue(); $this->dispatch('notify', 'Incidencia enviada a revisión'); } @@ -208,6 +260,7 @@ class IssueDetail extends Component 'resolved_at' => $this->issue->resolved_at ?? now(), 'resolution_notes' => $this->resolutionNotes ?: null, ]); + $this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'resolved')); $this->refreshIssue(); $this->dispatch('notify', 'Incidencia validada y resuelta'); } @@ -219,6 +272,7 @@ class IssueDetail extends Component 'status' => 'closed', 'resolved_at' => $this->issue->resolved_at ?? now(), ]); + $this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed')); $this->refreshIssue(); $this->dispatch('notify', 'Incidencia cerrada'); } @@ -227,6 +281,7 @@ class IssueDetail extends Component { abort_unless($this->canEdit(), 403); $this->issue->update(['status' => 'open', 'resolved_at' => null]); + $this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'open')); $this->refreshIssue(); $this->dispatch('notify', 'Incidencia reabierta'); } diff --git a/app/Livewire/IssueForm.php b/app/Livewire/IssueForm.php index e335423..a8e87bc 100644 --- a/app/Livewire/IssueForm.php +++ b/app/Livewire/IssueForm.php @@ -4,6 +4,7 @@ namespace App\Livewire; use App\Models\Issue; use App\Models\Project; +use App\Notifications\IssueAssignedNotification; use App\Notifications\IssueReportedNotification; use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; @@ -28,6 +29,7 @@ class IssueForm extends Component // Optional context (e.g. when reporting from a map feature) public $featureId = null; public $inspectionId = null; + public $featureName = null; // shown when the issue is pre-linked to a map element public function mount(Project $project, ?Issue $issue = null) { @@ -50,6 +52,14 @@ class IssueForm extends Component $this->resolutionNotes = $issue->resolution_notes ?? ''; $this->featureId = $issue->feature_id; $this->inspectionId = $issue->inspection_id; + $this->featureName = $issue->feature?->name; + } elseif ($featureId = request()->integer('feature')) { + // Pre-link to a map element when reporting from the project map. + $feature = \App\Models\Feature::with('layer.phase')->find($featureId); + if ($feature && $feature->layer?->phase?->project_id === $project->id) { + $this->featureId = $feature->id; + $this->featureName = $feature->name; + } } } @@ -92,12 +102,18 @@ class IssueForm extends Component $payload['resolved_at'] = in_array($this->status, ['resolved', 'closed']) ? now() : null; if ($this->issue) { + $previousAssignee = $this->issue->assigned_to; // Don't overwrite an existing resolved date if ($this->issue->resolved_at && in_array($this->status, ['resolved', 'closed'])) { unset($payload['resolved_at']); } $this->issue->update($payload); $issue = $this->issue; + + // Notify a newly assigned user (when it changed and isn't the current actor). + if ($issue->assigned_to && $issue->assigned_to !== $previousAssignee && $issue->assigned_to !== Auth::id()) { + $issue->assignee?->notify(new IssueAssignedNotification($issue)); + } } else { $issue = Issue::create(array_merge($payload, [ 'project_id' => $this->project->id, diff --git a/app/Livewire/IssueTable.php b/app/Livewire/IssueTable.php index 22eb4d4..9ce5a3f 100644 --- a/app/Livewire/IssueTable.php +++ b/app/Livewire/IssueTable.php @@ -43,6 +43,9 @@ class IssueTable extends DataTableComponent '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()), ]); } @@ -81,6 +84,9 @@ class IssueTable extends DataTableComponent '.$row->tasks_done_count.'/'.$row->tasks_count.' tareas '; } + if ($row->overdue_tasks_count) { + $html .= '
⏰ '.$row->overdue_tasks_count.' vencida'.($row->overdue_tasks_count > 1 ? 's' : '').'
'; + } return $html; }) ->html(), diff --git a/app/Models/IssueChecklistTemplate.php b/app/Models/IssueChecklistTemplate.php new file mode 100644 index 0000000..b51f108 --- /dev/null +++ b/app/Models/IssueChecklistTemplate.php @@ -0,0 +1,20 @@ + 'array']; + + public function project() + { + return $this->belongsTo(Project::class); + } +} diff --git a/app/Models/IssueTask.php b/app/Models/IssueTask.php index bb3f51c..34cbbd7 100644 --- a/app/Models/IssueTask.php +++ b/app/Models/IssueTask.php @@ -11,16 +11,25 @@ class IssueTask extends Model protected $fillable = [ 'issue_id', 'title', 'is_done', 'done_at', 'done_by', - 'assigned_to', 'due_date', 'order', + 'assigned_to', 'due_date', 'order', 'overdue_notified_at', 'uuid', 'client_updated_at', ]; protected $casts = [ - 'is_done' => 'boolean', - 'done_at' => 'datetime', - 'due_date' => 'date', + 'is_done' => 'boolean', + 'done_at' => 'datetime', + 'due_date' => 'date', + 'overdue_notified_at' => 'datetime', ]; + /** Tasks that are past their due date and not yet completed. */ + public function scopeOverdue($q) + { + return $q->where('is_done', false) + ->whereNotNull('due_date') + ->whereDate('due_date', '<', now()->toDateString()); + } + public function issue() { return $this->belongsTo(Issue::class); } public function assignee() { return $this->belongsTo(User::class, 'assigned_to'); } public function completer() { return $this->belongsTo(User::class, 'done_by'); } diff --git a/app/Notifications/IssueAssignedNotification.php b/app/Notifications/IssueAssignedNotification.php new file mode 100644 index 0000000..ed26e35 --- /dev/null +++ b/app/Notifications/IssueAssignedNotification.php @@ -0,0 +1,30 @@ + 'issue_assigned', + 'issue_id' => $this->issue->id, + 'project_id' => $this->issue->project_id, + 'priority' => $this->issue->priority, + 'message' => "Se te ha asignado la incidencia '{$this->issue->title}'", + ]; + } +} diff --git a/app/Notifications/IssueCommentedNotification.php b/app/Notifications/IssueCommentedNotification.php new file mode 100644 index 0000000..164772f --- /dev/null +++ b/app/Notifications/IssueCommentedNotification.php @@ -0,0 +1,33 @@ +comment->issue; + + return [ + 'type' => 'issue_commented', + 'issue_id' => $this->comment->issue_id, + 'project_id' => $issue?->project_id, + 'author' => $this->comment->user?->name, + 'message' => "{$this->comment->user?->name} comentó en '{$issue?->title}': " . Str::limit($this->comment->body, 60), + ]; + } +} diff --git a/app/Notifications/IssueStatusChangedNotification.php b/app/Notifications/IssueStatusChangedNotification.php new file mode 100644 index 0000000..61dc309 --- /dev/null +++ b/app/Notifications/IssueStatusChangedNotification.php @@ -0,0 +1,37 @@ + 'reabierta', + 'in_review' => 'enviada a revisión', + 'resolved' => 'resuelta', + 'closed' => 'cerrada', + ][$this->status] ?? $this->status; + + return [ + 'type' => 'issue_status_changed', + 'issue_id' => $this->issue->id, + 'project_id' => $this->issue->project_id, + 'status' => $this->status, + 'message' => "La incidencia '{$this->issue->title}' ha sido {$label}", + ]; + } +} diff --git a/app/Notifications/IssueTaskAssignedNotification.php b/app/Notifications/IssueTaskAssignedNotification.php new file mode 100644 index 0000000..90be82c --- /dev/null +++ b/app/Notifications/IssueTaskAssignedNotification.php @@ -0,0 +1,31 @@ + 'issue_task_assigned', + 'issue_id' => $this->task->issue_id, + 'task_id' => $this->task->id, + 'project_id' => $this->task->issue?->project_id, + 'due_date' => $this->task->due_date?->toDateString(), + 'message' => "Se te ha asignado la tarea '{$this->task->title}'", + ]; + } +} diff --git a/app/Notifications/IssueTaskOverdueNotification.php b/app/Notifications/IssueTaskOverdueNotification.php new file mode 100644 index 0000000..f89f60e --- /dev/null +++ b/app/Notifications/IssueTaskOverdueNotification.php @@ -0,0 +1,31 @@ + 'issue_task_overdue', + 'issue_id' => $this->task->issue_id, + 'task_id' => $this->task->id, + 'project_id' => $this->task->issue?->project_id, + 'due_date' => $this->task->due_date?->toDateString(), + 'message' => "Tarea vencida: '{$this->task->title}' (venció el {$this->task->due_date?->format('d/m/Y')})", + ]; + } +} diff --git a/database/migrations/2026_06_18_130000_create_issue_checklist_templates_table.php b/database/migrations/2026_06_18_130000_create_issue_checklist_templates_table.php new file mode 100644 index 0000000..eacf829 --- /dev/null +++ b/database/migrations/2026_06_18_130000_create_issue_checklist_templates_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('project_id')->constrained('projects')->cascadeOnDelete(); + $table->string('name'); + $table->json('items')->nullable(); // array of task titles + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('issue_checklist_templates'); + } +}; diff --git a/database/migrations/2026_06_18_130100_add_overdue_notified_at_to_issue_tasks_table.php b/database/migrations/2026_06_18_130100_add_overdue_notified_at_to_issue_tasks_table.php new file mode 100644 index 0000000..94f7036 --- /dev/null +++ b/database/migrations/2026_06_18_130100_add_overdue_notified_at_to_issue_tasks_table.php @@ -0,0 +1,22 @@ +timestamp('overdue_notified_at')->nullable()->after('due_date'); + }); + } + + public function down(): void + { + Schema::table('issue_tasks', function (Blueprint $table) { + $table->dropColumn('overdue_notified_at'); + }); + } +}; diff --git a/resources/views/livewire/issues/issue-checklist-manager.blade.php b/resources/views/livewire/issues/issue-checklist-manager.blade.php new file mode 100644 index 0000000..c8717e4 --- /dev/null +++ b/resources/views/livewire/issues/issue-checklist-manager.blade.php @@ -0,0 +1,98 @@ +
+ + + Volver a incidencias + + +
+
+

Plantillas de checklist

+

Listas de tareas reutilizables para incidencias recurrentes · {{ $project->name }}

+
+ +
+ + {{-- List --}} + @if($templates->isEmpty()) +
+ +

Sin plantillas

+

Crea una lista de tareas reutilizable para aplicarla a tus incidencias.

+
+ @else +
+ @foreach($templates as $t) +
+
+
+
{{ $t->name }}
+
{{ count($t->items ?: []) }} tareas: {{ Str::limit(implode(' · ', $t->items ?: []), 90) }}
+
+
+ + +
+
+
+ @endforeach +
+ @endif + + {{-- Form modal --}} + @if($showForm) +
+
+
+
+

{{ $editingId ? 'Editar plantilla' : 'Nueva plantilla' }}

+ +
+
+
+ + + @error('name'){{ $message }}@enderror +
+ +
+ +
+ @foreach($items as $i => $item) +
+ + +
+ @endforeach +
+ @error('items'){{ $message }}@enderror + +
+ +
+ + +
+
+
+
+ @endif +
diff --git a/resources/views/livewire/issues/issue-detail.blade.php b/resources/views/livewire/issues/issue-detail.blade.php index 27ac511..6adde44 100644 --- a/resources/views/livewire/issues/issue-detail.blade.php +++ b/resources/views/livewire/issues/issue-detail.blade.php @@ -161,6 +161,20 @@ + + @if(count($checklistTemplates)) +
+ Aplicar plantilla: + + +
+ @error('applyTemplateId'){{ $message }}@enderror + @endif @endif diff --git a/resources/views/livewire/issues/issue-form.blade.php b/resources/views/livewire/issues/issue-form.blade.php index 10f9af8..2c47b99 100644 --- a/resources/views/livewire/issues/issue-form.blade.php +++ b/resources/views/livewire/issues/issue-form.blade.php @@ -12,6 +12,13 @@

{{ $project->name }}

+ @if($featureName) +
+ + Vinculada al elemento del mapa: {{ $featureName }} +
+ @endif +
{{-- Título --}} diff --git a/resources/views/livewire/issues/issue-manager.blade.php b/resources/views/livewire/issues/issue-manager.blade.php index 4888334..2897668 100644 --- a/resources/views/livewire/issues/issue-manager.blade.php +++ b/resources/views/livewire/issues/issue-manager.blade.php @@ -7,16 +7,28 @@

Incidencias del proyecto

Gestión de incidencias y problemas

- @can('create issues') - - - Nueva incidencia - - @endcan +
+ @can('edit issues') + + + Plantillas + + @endcan + @can('create issues') + + + Nueva incidencia + + @endcan +
{{-- ================================================================ diff --git a/resources/views/livewire/projects/project-map.blade.php b/resources/views/livewire/projects/project-map.blade.php index d2b7bbe..57e33b9 100644 --- a/resources/views/livewire/projects/project-map.blade.php +++ b/resources/views/livewire/projects/project-map.blade.php @@ -156,6 +156,14 @@

{{ $selectedFeature->name ?? __('Feature') }}

{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }} · {{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}

+ @can('create issues') + + + {{ __('Incidencia') }} + + @endcan {{-- En pantalla completa el contenido se reparte en columnas --}} diff --git a/routes/console.php b/routes/console.php index 3c9adf1..f6b454f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Avisar a los asignados de tareas de incidencia vencidas (una vez por tarea). +Schedule::command('issues:notify-overdue')->dailyAt('07:00'); diff --git a/routes/web.php b/routes/web.php index bd81dbc..29a3c46 100644 --- a/routes/web.php +++ b/routes/web.php @@ -120,6 +120,7 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa // Issues del proyecto Route::get('/projects/{project}/issues', \App\Livewire\IssueManager::class)->name('projects.issues'); Route::get('/projects/{project}/issues/create', \App\Livewire\IssueForm::class)->name('projects.issues.create'); + Route::get('/projects/{project}/issues/checklists', \App\Livewire\IssueChecklistManager::class)->name('projects.issues.checklists'); Route::get('/projects/{project}/issues/{issue}', \App\Livewire\IssueDetail::class)->name('projects.issues.show'); Route::get('/projects/{project}/issues/{issue}/edit', \App\Livewire\IssueForm::class)->name('projects.issues.edit'); diff --git a/tests/Feature/IssuesEnhancementsTest.php b/tests/Feature/IssuesEnhancementsTest.php new file mode 100644 index 0000000..1524f3e --- /dev/null +++ b/tests/Feature/IssuesEnhancementsTest.php @@ -0,0 +1,199 @@ +user = User::factory()->create(); + $this->user->givePermissionTo(['view issues', 'create issues', 'edit issues', 'delete issues', 'upload media']); + $this->assignee = User::factory()->create(); + + $this->project = Project::create([ + 'reference' => 'ENH-1', + 'name' => 'Proyecto Enh', + 'address' => 'Calle 1', + 'lat' => 40.0, + 'lng' => -3.0, + 'start_date' => now()->toDateString(), + 'end_date_estimated' => now()->addMonths(3)->toDateString(), + 'status' => 'in_progress', + 'created_by' => $this->user->id, + ]); + $this->project->users()->attach($this->user->id, ['role_in_project' => 'supervisor']); + $this->project->users()->attach($this->assignee->id, ['role_in_project' => 'worker']); + } + + private function makeIssue(array $attrs = []): Issue + { + return Issue::create(array_merge([ + 'project_id' => $this->project->id, + 'title' => 'Incidencia enh', + 'status' => 'open', + 'priority' => 'medium', + 'reported_by' => $this->user->id, + ], $attrs)); + } + + private function makeFeature(): Feature + { + $phase = Phase::create([ + 'project_id' => $this->project->id, 'name' => 'F', 'order' => 1, + 'color' => '#000', 'progress_percent' => 0, + ]); + $layer = Layer::create([ + 'project_id' => $this->project->id, 'phase_id' => $phase->id, + 'name' => 'L', 'color' => '#111', 'uploaded_by' => $this->user->id, + ]); + return Feature::create([ + 'layer_id' => $layer->id, 'name' => 'Muro norte', + 'geometry' => ['type' => 'Point', 'coordinates' => [-3.0, 40.0]], + 'progress' => 0, 'status' => 'planned', + ]); + } + + // ── Notifications ──────────────────────────────────────────────────────────── + + public function test_assigning_a_task_notifies_the_assignee(): void + { + Notification::fake(); + $issue = $this->makeIssue(); + + Livewire::actingAs($this->user) + ->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue]) + ->set('newTaskTitle', 'Sanear zona') + ->set('newTaskAssignee', $this->assignee->id) + ->call('addTask'); + + Notification::assertSentTo($this->assignee, IssueTaskAssignedNotification::class); + } + + public function test_commenting_notifies_the_assignee(): void + { + Notification::fake(); + $issue = $this->makeIssue(['assigned_to' => $this->assignee->id]); + + Livewire::actingAs($this->user) + ->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue]) + ->set('newComment', 'Revisado en obra') + ->call('addComment'); + + Notification::assertSentTo($this->assignee, IssueCommentedNotification::class); + } + + public function test_resolving_notifies_stakeholders(): void + { + Notification::fake(); + $issue = $this->makeIssue(['assigned_to' => $this->assignee->id]); + + Livewire::actingAs($this->user) + ->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue]) + ->call('verifyResolve'); + + Notification::assertSentTo($this->assignee, IssueStatusChangedNotification::class); + $this->assertEquals('resolved', $issue->fresh()->status); + } + + // ── Checklist templates ──────────────────────────────────────────────────────── + + public function test_checklist_manager_creates_a_template(): void + { + Livewire::actingAs($this->user) + ->test(IssueChecklistManager::class, ['project' => $this->project]) + ->set('name', 'Reparación grieta') + ->set('items', ['Picar', 'Sellar', 'Pintar']) + ->call('save'); + + $this->assertDatabaseHas('issue_checklist_templates', [ + 'project_id' => $this->project->id, 'name' => 'Reparación grieta', + ]); + } + + public function test_applying_a_template_creates_the_tasks(): void + { + $issue = $this->makeIssue(); + $template = IssueChecklistTemplate::create([ + 'project_id' => $this->project->id, + 'name' => 'Plantilla', + 'items' => ['Picar', 'Sellar', 'Pintar'], + ]); + + Livewire::actingAs($this->user) + ->test(IssueDetail::class, ['project' => $this->project, 'issue' => $issue]) + ->set('applyTemplateId', $template->id) + ->call('applyTemplate'); + + $this->assertEquals(3, $issue->tasks()->count()); + $this->assertEqualsCanonicalizing(['Picar', 'Sellar', 'Pintar'], $issue->tasks()->pluck('title')->all()); + } + + // ── Overdue alerts ─────────────────────────────────────────────────────────── + + public function test_overdue_command_notifies_assignee_once(): void + { + Notification::fake(); + $issue = $this->makeIssue(); + $task = $issue->tasks()->create([ + 'title' => 'Tarea vencida', + 'assigned_to' => $this->assignee->id, + 'due_date' => now()->subDays(2)->toDateString(), + 'is_done' => false, + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + ]); + + $this->artisan('issues:notify-overdue')->assertSuccessful(); + $this->artisan('issues:notify-overdue')->assertSuccessful(); // idempotent + + Notification::assertSentTo($this->assignee, IssueTaskOverdueNotification::class); + Notification::assertCount(1); + $this->assertNotNull($task->fresh()->overdue_notified_at); + } + + // ── Report issue from a map feature ────────────────────────────────────────── + + public function test_issue_form_prelinks_feature_from_query(): void + { + $feature = $this->makeFeature(); + + // The create form mounts during the GET request, where it reads ?feature=. + $this->actingAs($this->user) + ->get(route('projects.issues.create', ['project' => $this->project, 'feature' => $feature->id])) + ->assertOk() + ->assertSee('Muro norte') + ->assertSee('Vinculada al elemento del mapa'); + } +}