latest(); // Filter by type if ($type = $request->string('issue_type')->toString()) { $query->where('issue_type', $type); } // Filter by status if ($status = $request->string('status')->toString()) { $query->where('status', $status); } // Filter by priority if ($priority = $request->string('priority')->toString()) { $query->where('priority', $priority); } // Filter by assignee if ($assigneeId = $request->integer('assigned_to')) { $query->where('assigned_to_user_id', $assigneeId); } // Filter by creator if ($creatorId = $request->integer('created_by')) { $query->where('created_by_user_id', $creatorId); } // Filter by label if ($labelId = $request->integer('label')) { $query->withLabel($labelId); } // Filter by due date range if ($dueDateFrom = $request->string('due_date_from')->toString()) { $query->whereDate('due_date', '>=', $dueDateFrom); } if ($dueDateTo = $request->string('due_date_to')->toString()) { $query->whereDate('due_date', '<=', $dueDateTo); } // Text search if ($search = $request->string('search')->toString()) { $query->where(function ($q) use ($search) { $q->where('issue_number', 'like', "%{$search}%") ->orWhere('title', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%"); }); } // Show only open issues by default if ($request->string('show_closed')->toString() !== '1') { $query->open(); } $issues = $query->paginate(20)->withQueryString(); // Get filters for dropdowns $users = User::orderBy('name')->get(); $labels = IssueLabel::orderBy('name')->get(); // Get summary stats $stats = [ 'total_open' => Issue::open()->count(), 'assigned_to_me' => Issue::assignedTo(Auth::id())->open()->count(), 'overdue' => Issue::overdue()->count(), 'high_priority' => Issue::byPriority(Issue::PRIORITY_HIGH)->open()->count() + Issue::byPriority(Issue::PRIORITY_URGENT)->open()->count(), ]; return view('admin.issues.index', compact('issues', 'users', 'labels', 'stats')); } public function create() { $users = User::orderBy('name')->get(); $labels = IssueLabel::orderBy('name')->get(); $members = Member::orderBy('full_name')->get(); $openIssues = Issue::open()->orderBy('issue_number')->get(); return view('admin.issues.create', compact('users', 'labels', 'members', 'openIssues')); } public function store(Request $request) { $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'issue_type' => ['required', Rule::in([ Issue::TYPE_WORK_ITEM, Issue::TYPE_PROJECT_TASK, Issue::TYPE_MAINTENANCE, Issue::TYPE_MEMBER_REQUEST, ])], 'priority' => ['required', Rule::in([ Issue::PRIORITY_LOW, Issue::PRIORITY_MEDIUM, Issue::PRIORITY_HIGH, Issue::PRIORITY_URGENT, ])], 'assigned_to_user_id' => ['nullable', 'exists:users,id'], 'member_id' => ['nullable', 'exists:members,id'], 'parent_issue_id' => ['nullable', 'exists:issues,id'], 'due_date' => ['nullable', 'date'], 'estimated_hours' => ['nullable', 'numeric', 'min:0'], 'labels' => ['nullable', 'array'], 'labels.*' => ['exists:issue_labels,id'], ]); $issue = DB::transaction(function () use ($validated, $request) { $issue = Issue::create([ ...$validated, 'created_by_user_id' => Auth::id(), 'status' => $validated['assigned_to_user_id'] ? Issue::STATUS_ASSIGNED : Issue::STATUS_NEW, ]); // Attach labels if (!empty($validated['labels'])) { $issue->labels()->attach($validated['labels']); } // Auto-watch: creator and assignee $watchers = [Auth::id()]; if ($validated['assigned_to_user_id'] && $validated['assigned_to_user_id'] != Auth::id()) { $watchers[] = $validated['assigned_to_user_id']; } $issue->watchers()->attach(array_unique($watchers)); AuditLogger::log('issue.created', $issue, [ 'issue_number' => $issue->issue_number, 'title' => $issue->title, 'type' => $issue->issue_type, ]); return $issue; }); // Send email notification to assignee if ($issue->assigned_to_user_id && $issue->assignedTo) { Mail::to($issue->assignedTo->email)->queue(new IssueAssignedMail($issue)); } return redirect()->route('admin.issues.show', $issue) ->with('status', __('Issue created successfully.')); } public function show(Issue $issue) { $issue->load([ 'creator', 'assignee', 'reviewer', 'member', 'parentIssue', 'subTasks', 'labels', 'watchers', 'comments.user', 'attachments.user', 'timeLogs.user', 'relatedIssues', ]); $users = User::orderBy('name')->get(); $labels = IssueLabel::orderBy('name')->get(); return view('admin.issues.show', compact('issue', 'users', 'labels')); } public function edit(Issue $issue) { if ($issue->isClosed() && !Auth::user()->is_admin) { return redirect()->route('admin.issues.show', $issue) ->with('error', __('Cannot edit closed issues.')); } $users = User::orderBy('name')->get(); $labels = IssueLabel::orderBy('name')->get(); $members = Member::orderBy('full_name')->get(); $openIssues = Issue::open()->where('id', '!=', $issue->id)->orderBy('issue_number')->get(); return view('admin.issues.edit', compact('issue', 'users', 'labels', 'members', 'openIssues')); } public function update(Request $request, Issue $issue) { if ($issue->isClosed() && !Auth::user()->is_admin) { return redirect()->route('admin.issues.show', $issue) ->with('error', __('Cannot edit closed issues.')); } $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'issue_type' => ['required', Rule::in([ Issue::TYPE_WORK_ITEM, Issue::TYPE_PROJECT_TASK, Issue::TYPE_MAINTENANCE, Issue::TYPE_MEMBER_REQUEST, ])], 'priority' => ['required', Rule::in([ Issue::PRIORITY_LOW, Issue::PRIORITY_MEDIUM, Issue::PRIORITY_HIGH, Issue::PRIORITY_URGENT, ])], 'assigned_to_user_id' => ['nullable', 'exists:users,id'], 'reviewer_id' => ['nullable', 'exists:users,id'], 'member_id' => ['nullable', 'exists:members,id'], 'parent_issue_id' => ['nullable', 'exists:issues,id'], 'due_date' => ['nullable', 'date'], 'estimated_hours' => ['nullable', 'numeric', 'min:0'], 'labels' => ['nullable', 'array'], 'labels.*' => ['exists:issue_labels,id'], ]); $issue = DB::transaction(function () use ($issue, $validated) { $issue->update($validated); // Sync labels if (isset($validated['labels'])) { $issue->labels()->sync($validated['labels']); } AuditLogger::log('issue.updated', $issue, [ 'issue_number' => $issue->issue_number, ]); return $issue; }); return redirect()->route('admin.issues.show', $issue) ->with('status', __('Issue updated successfully.')); } public function destroy(Issue $issue) { if (!Auth::user()->is_admin) { abort(403, 'Only administrators can delete issues.'); } AuditLogger::log('issue.deleted', $issue, [ 'issue_number' => $issue->issue_number, 'title' => $issue->title, ]); $issue->delete(); return redirect()->route('admin.issues.index') ->with('status', __('Issue deleted successfully.')); } // ==================== Workflow Actions ==================== public function assign(Request $request, Issue $issue) { $validated = $request->validate([ 'assigned_to_user_id' => ['required', 'exists:users,id'], ]); $issue->update([ 'assigned_to_user_id' => $validated['assigned_to_user_id'], 'status' => Issue::STATUS_ASSIGNED, ]); // Add assignee as watcher if (!$issue->watchers->contains($validated['assigned_to_user_id'])) { $issue->watchers()->attach($validated['assigned_to_user_id']); } AuditLogger::log('issue.assigned', $issue, [ 'assigned_to' => $validated['assigned_to_user_id'], ]); // Send email notification to assignee if ($issue->assignedTo) { Mail::to($issue->assignedTo->email)->queue(new IssueAssignedMail($issue)); } return back()->with('status', __('Issue assigned successfully.')); } public function updateStatus(Request $request, Issue $issue) { $validated = $request->validate([ 'status' => ['required', Rule::in([ Issue::STATUS_NEW, Issue::STATUS_ASSIGNED, Issue::STATUS_IN_PROGRESS, Issue::STATUS_REVIEW, Issue::STATUS_CLOSED, ])], 'close_reason' => ['nullable', 'string', 'max:500'], ]); $newStatus = $validated['status']; $oldStatus = $issue->status; // Validate status transition if ($newStatus === Issue::STATUS_IN_PROGRESS && !$issue->canMoveToInProgress()) { return back()->with('error', __('Issue must be assigned before moving to in progress.')); } if ($newStatus === Issue::STATUS_REVIEW && !$issue->canMoveToReview()) { return back()->with('error', __('Issue must be in progress before moving to review.')); } if ($newStatus === Issue::STATUS_CLOSED && !$issue->canBeClosed()) { return back()->with('error', __('Cannot close issue in current state.')); } // Update status $updateData = ['status' => $newStatus]; if ($newStatus === Issue::STATUS_CLOSED) { $updateData['closed_at'] = now(); } elseif ($oldStatus === Issue::STATUS_CLOSED) { // Reopening $updateData['closed_at'] = null; } $issue->update($updateData); AuditLogger::log('issue.status_changed', $issue, [ 'from_status' => $oldStatus, 'to_status' => $newStatus, 'close_reason' => $validated['close_reason'] ?? null, ]); // Send email notifications to watchers if ($newStatus === Issue::STATUS_CLOSED) { // Send "closed" notification foreach ($issue->watchers as $watcher) { Mail::to($watcher->email)->queue(new IssueClosedMail($issue)); } } else { // Send "status changed" notification foreach ($issue->watchers as $watcher) { Mail::to($watcher->email)->queue(new IssueStatusChangedMail($issue, $oldStatus, $newStatus)); } } return back()->with('status', __('Issue status updated successfully.')); } public function addComment(Request $request, Issue $issue) { $validated = $request->validate([ 'comment_text' => ['required', 'string'], 'is_internal' => ['boolean'], ]); $comment = $issue->comments()->create([ 'user_id' => Auth::id(), 'comment_text' => $validated['comment_text'], 'is_internal' => $validated['is_internal'] ?? false, ]); AuditLogger::log('issue.commented', $issue, [ 'comment_id' => $comment->id, ]); // Notify watchers (except the comment author and skip internal comments for non-watchers) foreach ($issue->watchers as $watcher) { // Don't send notification to the person who added the comment if ($watcher->id === Auth::id()) { continue; } Mail::to($watcher->email)->queue(new IssueCommentedMail($issue, $comment)); } return back()->with('status', __('Comment added successfully.')); } public function uploadAttachment(Request $request, Issue $issue) { $validated = $request->validate([ 'file' => ['required', 'file', 'max:10240'], // 10MB max ]); $file = $request->file('file'); $fileName = $file->getClientOriginalName(); $filePath = $file->store('issue-attachments', 'private'); $attachment = $issue->attachments()->create([ 'user_id' => Auth::id(), 'file_name' => $fileName, 'file_path' => $filePath, 'file_size' => $file->getSize(), 'mime_type' => $file->getMimeType(), ]); AuditLogger::log('issue.file_attached', $issue, [ 'file_name' => $fileName, 'attachment_id' => $attachment->id, ]); return back()->with('status', __('File uploaded successfully.')); } public function downloadAttachment(IssueAttachment $attachment) { if (!Storage::exists($attachment->file_path)) { abort(404, 'File not found.'); } return Storage::download($attachment->file_path, $attachment->file_name); } public function deleteAttachment(IssueAttachment $attachment) { $issueId = $attachment->issue_id; AuditLogger::log('issue.file_deleted', $attachment->issue, [ 'file_name' => $attachment->file_name, 'attachment_id' => $attachment->id, ]); $attachment->delete(); return redirect()->route('admin.issues.show', $issueId) ->with('status', __('Attachment deleted successfully.')); } public function logTime(Request $request, Issue $issue) { $validated = $request->validate([ 'hours' => ['required', 'numeric', 'min:0.01', 'max:999.99'], 'description' => ['nullable', 'string', 'max:500'], 'logged_at' => ['required', 'date'], ]); $timeLog = $issue->timeLogs()->create([ 'user_id' => Auth::id(), 'hours' => $validated['hours'], 'description' => $validated['description'], 'logged_at' => $validated['logged_at'], ]); AuditLogger::log('issue.time_logged', $issue, [ 'hours' => $validated['hours'], 'time_log_id' => $timeLog->id, ]); return back()->with('status', __('Time logged successfully.')); } public function addWatcher(Request $request, Issue $issue) { $validated = $request->validate([ 'user_id' => ['required', 'exists:users,id'], ]); if ($issue->watchers->contains($validated['user_id'])) { return back()->with('error', __('User is already watching this issue.')); } $issue->watchers()->attach($validated['user_id']); AuditLogger::log('issue.watcher_added', $issue, [ 'watcher_id' => $validated['user_id'], ]); return back()->with('status', __('Watcher added successfully.')); } public function removeWatcher(Request $request, Issue $issue) { $validated = $request->validate([ 'user_id' => ['required', 'exists:users,id'], ]); $issue->watchers()->detach($validated['user_id']); AuditLogger::log('issue.watcher_removed', $issue, [ 'watcher_id' => $validated['user_id'], ]); return back()->with('status', __('Watcher removed successfully.')); } }