546 خطوط
23 KiB
PHP
546 خطوط
23 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Resources\SprintResource;
|
||
use App\Http\Resources\TaskResource;
|
||
use App\Models\Meeting;
|
||
use App\Models\MeetingType;
|
||
use App\Models\Sprint;
|
||
use App\Models\Task;
|
||
use App\Services\ResourceAccessService;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
class SprintController extends Controller
|
||
{
|
||
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
|
||
|
||
private array $statuses = ['planning', 'active', 'completed', 'cancelled'];
|
||
|
||
public function index(Request $request): JsonResponse
|
||
{
|
||
try {
|
||
$query = $this->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',
|
||
]);
|
||
}
|
||
}
|