resourceAccess->sprints($request->user()) ->with(['project', 'tasks', 'members']); if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); } if ($request->filled('status')) { $query->where('status', $request->status); } if ($request->boolean('mine')) { $query->whereHas('members', fn ($q) => $q->where('users.id', $request->user()->id)); } $perPage = min((int) $request->input('per_page', 15), 100); $sortBy = in_array($request->input('sort_by'), ['id', 'title', 'status', 'created_at', 'updated_at', 'start_date', 'end_date'], true) ? $request->input('sort_by') : 'created_at'; $sortDir = $request->input('sort_dir') === 'asc' ? 'asc' : 'desc'; $sprints = $query->orderBy($sortBy, $sortDir)->paginate($perPage); return response()->json([ 'success' => true, 'data' => SprintResource::collection($sprints), 'meta' => [ 'current_page' => $sprints->currentPage(), 'last_page' => $sprints->lastPage(), 'per_page' => $sprints->perPage(), 'total' => $sprints->total(), ], 'message' => 'لیست Sprintها', ]); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در دریافت Sprintها'], 500); } } public function show(Sprint $sprint): JsonResponse { try { $sprint->load([ 'project', 'tasks.project', 'tasks.assignee', 'tasks.reporter', 'members', 'creator', 'retrospective', ]); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint), 'message' => 'اطلاعات Sprint', ]); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در دریافت اطلاعات Sprint'], 500); } } public function store(Request $request): JsonResponse { try { $data = $this->validatedSprintData($request); abort_unless( $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), 403, 'شما به پروژه انتخاب‌شده دسترسی ندارید.' ); $memberIds = $data['member_ids'] ?? []; $meetingSetup = $data['meeting_setup'] ?? []; unset($data['member_ids']); unset($data['meeting_setup']); $data['created_by'] = $request->user()->id; $data['status'] = 'planning'; $sprint = DB::transaction(function () use ($data, $memberIds, $meetingSetup, $request) { $sprint = Sprint::create($data); $sprint->members()->sync($memberIds); foreach ($meetingSetup as $setup) { $type = MeetingType::where('key', $setup['type_key'])->where('is_active', true)->first(); if (! $type) { continue; } Meeting::create([ 'title' => $setup['title'] ?? $type->default_title ?? $type->name, 'project_id' => $sprint->project_id, 'sprint_id' => $sprint->id, 'meeting_type_id' => $type->id, 'meeting_type' => $type->key, 'date' => $setup['date'], 'start_time' => $setup['start_time'] ?? null, 'end_time' => $setup['end_time'] ?? null, 'location' => $setup['location'] ?? null, 'meeting_link' => $setup['meeting_link'] ?? null, 'agenda' => $type->agenda_template, 'status' => 'scheduled', 'owner_id' => $setup['owner_id'] ?? $request->user()->id, 'facilitator_id' => $setup['facilitator_id'] ?? null, 'reminder_minutes' => $setup['reminder_minutes'] ?? 30, 'recurrence_rule' => $setup['recurrence_rule'] ?? $type->recurrence_rule, 'created_by' => $request->user()->id, ]); } return $sprint; }); $sprint->load(['project', 'tasks', 'members']); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint), 'message' => 'Sprint با موفقیت ایجاد شد', ], 201); } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در ایجاد Sprint'], 500); } } public function update(Request $request, Sprint $sprint): JsonResponse { try { $data = $this->validatedSprintData($request, true); if (isset($data['project_id'])) { abort_unless( $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), 403, 'شما به پروژه انتخاب‌شده دسترسی ندارید.' ); } $memberIds = $data['member_ids'] ?? null; unset($data['member_ids']); DB::transaction(function () use ($sprint, $data, $memberIds) { $sprint->update($data); if (is_array($memberIds)) { $sprint->members()->sync($memberIds); } }); $sprint->load(['project', 'tasks', 'members', 'retrospective']); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint->fresh()->load(['project', 'tasks', 'members', 'retrospective'])), 'message' => 'Sprint به‌روزرسانی شد', ]); } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در به‌روزرسانی Sprint'], 500); } } public function destroy(Sprint $sprint): JsonResponse { try { $sprint->delete(); return response()->json(['success' => true, 'message' => 'Sprint حذف شد']); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در حذف Sprint'], 500); } } public function availableTasks(Sprint $sprint): JsonResponse { try { $taskIdsInSprint = $sprint->tasks()->pluck('tasks.id'); $tasks = Task::with(['project', 'assignee']) ->withCount(['comments', 'files']) ->where('project_id', $sprint->project_id) ->whereNotIn('id', $taskIdsInSprint) ->whereNotIn('status', ['done', 'canceled']) ->orderBy('created_at', 'desc') ->get(); return response()->json([ 'success' => true, 'data' => TaskResource::collection($tasks), 'message' => 'تسک‌های قابل افزودن به Sprint', ]); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در دریافت تسک‌های قابل افزودن'], 500); } } public function addTask(Request $request, Sprint $sprint): JsonResponse { try { $request->validate(['task_id' => 'required|exists:tasks,id']); $task = Task::findOrFail($request->task_id); if ($task->project_id !== $sprint->project_id) { return response()->json(['success' => false, 'message' => 'این تسک متعلق به پروژه Sprint نیست'], 422); } $sprint->tasks()->syncWithoutDetaching([$task->id]); return response()->json(['success' => true, 'message' => 'تسک به Sprint اضافه شد']); } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در اضافه کردن تسک به Sprint'], 500); } } public function removeTask(Sprint $sprint, Task $task): JsonResponse { try { $sprint->tasks()->detach($task->id); return response()->json(['success' => true, 'message' => 'تسک از Sprint حذف شد']); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در حذف تسک از Sprint'], 500); } } public function updateTaskStatus(Request $request, Sprint $sprint, Task $task): JsonResponse { try { $request->validate([ 'status' => 'required|string|in:waiting,todo,in_progress,review,done', ]); if (! $sprint->tasks()->where('tasks.id', $task->id)->exists()) { return response()->json(['success' => false, 'message' => 'این تسک در Sprint انتخاب‌شده نیست'], 404); } $task->update(['status' => $request->status]); return response()->json([ 'success' => true, 'data' => new TaskResource($task->fresh()->load(['project', 'assignee'])), 'message' => 'وضعیت تسک به‌روزرسانی شد', ]); } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در تغییر وضعیت تسک'], 500); } } public function updateStatus(Request $request, Sprint $sprint): JsonResponse { try { $request->validate(['status' => 'required|string|in:'.implode(',', $this->statuses)]); $sprint->update(['status' => $request->status]); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint->fresh()->load(['project', 'tasks', 'members'])), 'message' => 'وضعیت Sprint به‌روزرسانی شد', ]); } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در به‌روزرسانی وضعیت Sprint'], 500); } } public function start(Sprint $sprint): JsonResponse { try { if ($sprint->status !== 'planning') { return response()->json(['success' => false, 'message' => 'فقط Sprint برنامه‌ریزی شده قابل شروع است'], 422); } $activeSprint = Sprint::where('project_id', $sprint->project_id) ->where('status', 'active') ->where('id', '!=', $sprint->id) ->first(); if ($activeSprint) { return response()->json([ 'success' => false, 'message' => "در این پروژه Sprint فعال دیگری وجود دارد: {$activeSprint->title}", ], 422); } $sprint->update(['status' => 'active']); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint->fresh()->load(['project', 'tasks', 'members'])), 'message' => 'Sprint شروع شد', ]); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در شروع Sprint'], 500); } } public function end(Request $request, Sprint $sprint): JsonResponse { try { $request->validate([ 'incomplete_action' => 'required|string|in:move_to_next,backlog,keep', 'target_sprint_id' => 'nullable|required_if:incomplete_action,move_to_next|exists:sprints,id', ]); if ($sprint->status !== 'active') { return response()->json(['success' => false, 'message' => 'فقط Sprint فعال قابل پایان دادن است'], 422); } $incompleteTasks = $sprint->tasks()->where('status', '!=', 'done')->get(); $completedTasks = $sprint->tasks()->where('status', 'done')->count(); $totalTasks = $sprint->tasks()->count(); DB::transaction(function () use ($request, $sprint, $incompleteTasks, $totalTasks, $completedTasks) { if ($request->incomplete_action === 'move_to_next') { $targetSprint = Sprint::where('id', $request->target_sprint_id) ->where('project_id', $sprint->project_id) ->where('status', 'planning') ->firstOrFail(); $targetSprint->tasks()->syncWithoutDetaching($incompleteTasks->pluck('id')->all()); $sprint->tasks()->detach($incompleteTasks->pluck('id')->all()); } if (in_array($request->incomplete_action, ['backlog', 'keep'], true)) { $sprint->tasks()->detach($incompleteTasks->pluck('id')->all()); } $sprint->update([ 'status' => 'completed', 'completed_at' => now(), 'completion_summary' => [ 'total_tasks' => $totalTasks, 'completed_tasks' => $completedTasks, 'incomplete_tasks' => $incompleteTasks->count(), 'incomplete_action' => $request->incomplete_action, ], ]); }); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint->fresh()->load(['project', 'tasks', 'members', 'retrospective'])), 'message' => 'Sprint پایان یافت', ]); } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در پایان Sprint'], 500); } } public function cancel(Sprint $sprint): JsonResponse { try { if (! in_array($sprint->status, ['planning', 'active'], true)) { return response()->json(['success' => false, 'message' => 'این Sprint قابل لغو نیست'], 422); } $sprint->update(['status' => 'cancelled']); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint->fresh()->load(['project', 'tasks', 'members'])), 'message' => 'Sprint لغو شد', ]); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در لغو Sprint'], 500); } } public function report(Sprint $sprint): JsonResponse { try { $sprint->load(['tasks.assignee', 'members']); $tasks = $sprint->tasks; $totalTasks = $tasks->count(); $completedTasks = $tasks->where('status', 'done')->count(); $incompleteTasks = $totalTasks - $completedTasks; return response()->json([ 'success' => true, 'data' => [ 'total_tasks' => $totalTasks, 'completed_tasks' => $completedTasks, 'incomplete_tasks' => $incompleteTasks, 'completion_percentage' => $totalTasks > 0 ? round(($completedTasks / $totalTasks) * 100, 2) : 0, 'completed_by_member' => $tasks ->where('status', 'done') ->groupBy(fn ($task) => $task->assignee?->name ?? 'بدون مسئول') ->map(fn ($items, $name) => ['name' => $name, 'completed' => $items->count()]) ->values(), 'unfinished_tasks' => TaskResource::collection($tasks->where('status', '!=', 'done')->values()), ], 'message' => 'گزارش Sprint', ]); } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در دریافت گزارش Sprint'], 500); } } public function updateRetrospective(Request $request, Sprint $sprint): JsonResponse { try { $request->validate([ 'went_well' => 'nullable|string', 'problems' => 'nullable|string', 'improvements' => 'nullable|string', ]); $sprint->retrospective()->updateOrCreate( ['sprint_id' => $sprint->id], [ 'went_well' => $request->went_well, 'problems' => $request->problems, 'improvements' => $request->improvements, 'updated_by' => $request->user()->id, ] ); return response()->json([ 'success' => true, 'data' => new SprintResource($sprint->fresh()->load(['project', 'tasks', 'members', 'retrospective'])), 'message' => 'یادداشت پایان Sprint ذخیره شد', ]); } catch (ValidationException $e) { throw $e; } catch (\Exception $e) { return response()->json(['success' => false, 'message' => 'خطا در ذخیره یادداشت پایان Sprint'], 500); } } public function workspace(Sprint $sprint): JsonResponse { $sprint->load([ 'project', 'tasks.assignee', 'members', 'retrospective', 'meetings.type', 'meetings.participants', 'decisions', 'actionItems', 'blockers', 'scopeChanges', ]); $tasks = $sprint->tasks; $total = $tasks->count(); $completed = $tasks->where('status', 'done')->count(); $blocked = $tasks->filter(fn (Task $task) => $task->is_blocked)->count() + $sprint->blockers->whereNotIn('status', ['resolved', 'closed'])->count(); $overdue = $tasks->filter(fn (Task $task) => $task->due_date?->isPast() && $task->status !== 'done')->count(); $progress = $total > 0 ? round(($completed / $total) * 100) : 0; $today = Carbon::today(); $duration = max($sprint->start_date?->diffInDays($sprint->end_date) ?? 1, 1); $elapsed = max($sprint->start_date?->diffInDays($today, false) ?? 0, 0); $expected = min(100, round(($elapsed / $duration) * 100)); $health = 'on_track'; if ($sprint->end_date?->isPast() && $progress < 100) { $health = 'off_track'; } elseif ($blocked > 1 || $overdue > 2 || $progress + 20 < $expected) { $health = 'at_risk'; } $upcomingMeeting = $sprint->meetings ->whereNotIn('status', ['completed', 'cancelled']) ->filter(fn (Meeting $meeting) => $meeting->date?->gte($today)) ->sortBy(fn (Meeting $meeting) => $meeting->date?->format('Y-m-d').' '.$meeting->start_time) ->first(); return response()->json([ 'success' => true, 'data' => [ 'sprint' => new SprintResource($sprint), 'health' => [ 'status' => $health, 'progress' => $progress, 'expected_progress' => $expected, 'remaining_days' => max($today->diffInDays($sprint->end_date, false), 0), 'open_blockers' => $blocked, 'overdue_tasks' => $overdue, ], 'progress' => [ 'total_tasks' => $total, 'completed_tasks' => $completed, 'in_progress_tasks' => $tasks->where('status', 'in_progress')->count(), 'blocked_tasks' => $blocked, 'overdue_tasks' => $overdue, ], 'upcoming_meeting' => $upcomingMeeting, 'meetings' => $sprint->meetings->values(), 'decisions' => $sprint->decisions, 'action_items' => $sprint->actionItems, 'blockers' => $sprint->blockers, 'scope_changes' => $sprint->scopeChanges, ], 'message' => 'فضای کاری Sprint', ]); } private function validatedSprintData(Request $request, bool $partial = false): array { $required = $partial ? 'sometimes|required' : 'required'; return $request->validate([ 'title' => "{$required}|string|max:255", 'project_id' => "{$required}|exists:projects,id", 'goal' => 'nullable|string|max:1000', 'capacity_hours' => 'nullable|numeric|min:0', 'start_date' => "{$required}|date", 'end_date' => "{$required}|date|after_or_equal:start_date", 'member_ids' => 'nullable|array', 'member_ids.*' => 'exists:users,id', 'meeting_setup' => 'nullable|array', 'meeting_setup.*.type_key' => 'required|string|exists:meeting_types,key', 'meeting_setup.*.title' => 'nullable|string|max:255', 'meeting_setup.*.date' => 'required|date', 'meeting_setup.*.start_time' => 'nullable|date_format:H:i', 'meeting_setup.*.end_time' => 'nullable|date_format:H:i|after:meeting_setup.*.start_time', 'meeting_setup.*.location' => 'nullable|string|max:255', 'meeting_setup.*.meeting_link' => 'nullable|url', 'meeting_setup.*.owner_id' => 'nullable|exists:users,id', 'meeting_setup.*.facilitator_id' => 'nullable|exists:users,id', 'meeting_setup.*.reminder_minutes' => 'nullable|integer|min:0|max:10080', 'meeting_setup.*.recurrence_rule' => 'nullable|array', ]); } }