project_id === $project->id, 404); $this->project = $project; $this->issue = $issue; abort_unless($this->canAccessProject() && Auth::user()->can('view issues'), 403); $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(); } private function canAccessProject(): bool { $user = Auth::user(); return $user->can('manage all') || $this->project->users()->where('user_id', $user->id)->exists(); } private function canEdit(): bool { 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([ 'reporter', 'assignee', 'feature', 'tasks.assignee', 'tasks.completer', 'comments.user', 'comments.media', 'media.uploader', ]); } // ── Checklist ──────────────────────────────────────────────────────────────── public function addTask(): void { abort_unless($this->canEdit(), 403); $this->validate([ 'newTaskTitle' => 'required|string|max:255', 'newTaskAssignee' => 'nullable|exists:users,id', 'newTaskDue' => 'nullable|date', ]); $task = $this->issue->tasks()->create([ 'title' => $this->newTaskTitle, 'assigned_to' => $this->newTaskAssignee ?: null, 'due_date' => $this->newTaskDue ?: null, 'order' => ((int) $this->issue->tasks()->max('order')) + 1, '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); $task = $this->issue->tasks()->findOrFail($taskId); $done = ! $task->is_done; $task->update([ 'is_done' => $done, 'done_at' => $done ? now() : null, 'done_by' => $done ? Auth::id() : null, ]); $this->refreshIssue(); } public function deleteTask($taskId): void { abort_unless($this->canEdit(), 403); $this->issue->tasks()->findOrFail($taskId)->delete(); $this->refreshIssue(); $this->dispatch('notify', 'Tarea eliminada'); } // ── Comments + photos ────────────────────────────────────────────────────────── public function addComment(): void { // Anyone who can view the issue (and is a member) may comment / report progress. $this->validate([ 'newComment' => 'nullable|string|max:5000', 'commentPhoto' => 'nullable|image|max:20480', // 20 MB ]); if (trim($this->newComment) === '' && ! $this->commentPhoto) { throw ValidationException::withMessages([ 'newComment' => 'Escribe un comentario o adjunta una foto.', ]); } $comment = $this->issue->comments()->create([ 'user_id' => Auth::id(), 'body' => trim($this->newComment) ?: '(foto)', 'uuid' => (string) \Illuminate\Support\Str::uuid(), ]); if ($this->commentPhoto) { abort_unless(Auth::user()->can('upload media'), 403); $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'); } public function uploadIssuePhotos(): void { abort_unless(Auth::user()->can('upload media'), 403); $this->validate(['issuePhotos.*' => 'required|image|max:20480']); foreach ($this->issuePhotos as $photo) { $this->storeUpload($photo, $this->issue, 'issue'); } $this->reset('issuePhotos'); $this->refreshIssue(); $this->dispatch('notify', 'Fotos subidas'); } public function deleteMedia($mediaId): void { $media = \App\Models\Media::findOrFail($mediaId); $user = Auth::user(); abort_unless($user->can('delete media') || $media->uploaded_by === $user->id, 403); $media->delete(); $this->refreshIssue(); $this->dispatch('notify', 'Foto eliminada'); } /** Store an uploaded file on the public disk and attach it to a parent model. */ private function storeUpload($file, $parent, string $entity): void { $mime = $file->getMimeType(); $path = $file->store("uploads/issues/{$this->issue->id}/{$entity}", 'public'); $parent->media()->create([ 'name' => $file->getClientOriginalName(), 'file_path' => $path, 'file_type' => $mime, 'file_extension' => $file->getClientOriginalExtension(), 'file_size' => $file->getSize(), 'category' => str_starts_with($mime, 'image/') ? 'image' : 'document', 'uploaded_by' => Auth::id(), 'uuid' => (string) \Illuminate\Support\Str::uuid(), ]); } // ── Status workflow (verification) ─────────────────────────────────────────────── public function sendToReview(): void { 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'); } public function verifyResolve(): void { abort_unless($this->canEdit(), 403); $this->issue->update([ 'status' => 'resolved', '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'); } public function closeIssue(): void { abort_unless($this->canEdit(), 403); $this->issue->update([ 'status' => 'closed', 'resolved_at' => $this->issue->resolved_at ?? now(), ]); $this->notifyStakeholders(new IssueStatusChangedNotification($this->issue, 'closed')); $this->refreshIssue(); $this->dispatch('notify', 'Incidencia cerrada'); } public function reopen(): void { 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'); } public function render() { return view('livewire.issues.issue-detail', [ 'canEdit' => $this->canEdit(), ]); } }