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 .= '
Listas de tareas reutilizables para incidencias recurrentes · {{ $project->name }}
+Sin plantillas
+Crea una lista de tareas reutilizable para aplicarla a tus incidencias.
+{{ $project->name }}
+ @if($featureName) +