permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); return Inertia::render('projects/settings/Board', [ 'backlogTicketStatuses' => TicketStatusResource::collection( $project->ticketStatuses() ->whereNull('board_column_id') ->get(), )->resolve(), 'boardColumns' => BoardColumnResource::collection( $project->boardColumns() ->with('ticketStatuses') ->get(), )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => session('status'), 'ticketStatuses' => TicketStatusResource::collection( $project->ticketStatuses() ->with(['allowedTargetStatuses.boardColumn', 'boardColumn']) ->get(), )->resolve(), ]); } public function store( StoreBoardColumnRequest $request, Project $project, ): RedirectResponse { abort_unless( $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $desiredPosition = Arr::get($validated, 'position') ?? $project->boardColumns()->count() + 1; $boardColumn = DB::transaction(function () use ($project, $validated, $desiredPosition): BoardColumn { $boardColumn = $project->boardColumns()->create([ 'name' => Arr::get($validated, 'name'), 'position' => $project->boardColumns()->count() + 1, ]); $boardColumn->ticketStatuses()->create([ 'name' => Arr::get($validated, 'name'), 'position' => 1, 'project_id' => $project->id, ]); $this->reorderColumns($project, $boardColumn, $desiredPosition); return $boardColumn; }); return to_route('projects.settings.board', $project) ->with('status', "{$boardColumn->name} was added to the board workflow."); } public function update( UpdateBoardColumnRequest $request, Project $project, BoardColumn $boardColumn, ): RedirectResponse { abort_unless( $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); DB::transaction(function () use ($boardColumn, $project, $validated): void { $boardColumn->update([ 'name' => Arr::get($validated, 'name'), ]); $this->reorderColumns($project, $boardColumn, Arr::get($validated, 'position')); }); return to_route('projects.settings.board', $project) ->with('status', "{$boardColumn->fresh()->name} was updated."); } public function destroy( Request $request, Project $project, BoardColumn $boardColumn, ): RedirectResponse { abort_unless( $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $deletedName = $boardColumn->name; DB::transaction(function () use ($boardColumn, $project): void { $boardColumn->ticketStatuses() ->get() ->each(function (TicketStatus $ticketStatus) use ($project): void { $ticketStatus->update([ 'board_column_id' => null, 'position' => $this->nextStatusPosition($project, null), ]); }); $boardColumn->delete(); $this->normalizeColumnPositions($project); }); return to_route('projects.settings.board', $project) ->with('status', "{$deletedName} was removed from the board workflow."); } public function storeStatus( StoreTicketStatusRequest $request, Project $project, ): RedirectResponse { abort_unless( $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $boardColumnId = Arr::get($validated, 'board_column_id'); $boardColumnId = $boardColumnId === null ? null : (int) $boardColumnId; $ticketStatus = DB::transaction(function () use ($boardColumnId, $project, $validated): TicketStatus { return $project->ticketStatuses()->create([ 'board_column_id' => $boardColumnId, 'name' => Arr::get($validated, 'name'), 'position' => $this->nextStatusPosition($project, $boardColumnId), ]); }); $ticketStatus->load('boardColumn'); return to_route('projects.settings.board', $project) ->with('status', "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}."); } public function updateStatus( UpdateTicketStatusRequest $request, Project $project, TicketStatus $ticketStatus, ): RedirectResponse { abort_unless( $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $ticketStatus->update([ 'name' => Arr::get($validated, 'name'), ]); return to_route('projects.settings.board', $project) ->with('status', "{$ticketStatus->fresh()->name} was updated."); } public function destroyStatus( Request $request, Project $project, TicketStatus $ticketStatus, ): RedirectResponse { abort_unless( $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $ticketStatus->load('boardColumn'); $boardColumnId = $ticketStatus->board_column_id; if ($this->statusContextQuery($project, $boardColumnId)->count() <= 1) { return to_route('projects.settings.board', $project) ->withErrors([ 'ticket_status_id' => "{$this->statusContextLabel($ticketStatus)} needs at least one ticket status.", ]); } $deletedName = $ticketStatus->name; $contextLabel = $this->statusContextLabel($ticketStatus); DB::transaction(function () use ($boardColumnId, $project, $ticketStatus): void { $replacementStatus = $this->statusContextQuery($project, $boardColumnId) ->whereKeyNot($ticketStatus->id) ->firstOrFail(); $ticketStatus->tickets()->update([ 'ticket_status_id' => $replacementStatus->id, ]); $ticketStatus->delete(); $this->normalizeStatusPositions($project, $boardColumnId); }); return to_route('projects.settings.board', $project) ->with('status', "{$deletedName} was removed from {$contextLabel}."); } public function updateStatusTransitions( UpdateTicketStatusTransitionsRequest $request, Project $project, TicketStatus $ticketStatus, ): RedirectResponse { abort_unless( $this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project), 403, ); $validated = $request->validated(); $allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', [])) ->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId) ->unique() ->values() ->all(); $ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds); return to_route('projects.settings.board', $project) ->with('status', "{$ticketStatus->name} move targets were updated."); } private function normalizeColumnPositions(Project $project): void { $this->persistOrderedColumns( $project->boardColumns() ->get() ->values(), ); } private function reorderColumns(Project $project, BoardColumn $movingColumn, int $desiredPosition): void { /** @var Collection $columns */ $columns = $project->boardColumns() ->whereKeyNot($movingColumn->id) ->get() ->values(); $insertAt = max(0, min($desiredPosition - 1, $columns->count())); $columns->splice($insertAt, 0, [$movingColumn]); $this->persistOrderedColumns($columns->values()); } /** * @param Collection $columns */ private function persistOrderedColumns(Collection $columns): void { $temporaryOffset = ((int) $columns->max('position')) + $columns->count() + 1; $columns->each(function (BoardColumn $column, int $index) use ($temporaryOffset): void { $column->forceFill([ 'position' => $temporaryOffset + $index, ])->saveQuietly(); }); $columns->each(function (BoardColumn $column, int $index): void { $column->forceFill([ 'position' => $index + 1, ])->saveQuietly(); }); } private function nextStatusPosition(Project $project, ?int $boardColumnId): int { return $this->statusContextQuery($project, $boardColumnId)->count() + 1; } private function normalizeStatusPositions(Project $project, ?int $boardColumnId): void { $this->statusContextQuery($project, $boardColumnId) ->get() ->values() ->each(function (TicketStatus $ticketStatus, int $index): void { $ticketStatus->forceFill([ 'position' => $index + 1, ])->saveQuietly(); }); } private function statusContextLabel(TicketStatus $ticketStatus): string { return $ticketStatus->boardColumn?->name ?? 'Backlog'; } private function statusContextQuery(Project $project, ?int $boardColumnId): HasMany { return $project->ticketStatuses() ->when( $boardColumnId === null, fn ($query) => $query->whereNull('board_column_id'), fn ($query) => $query->where('board_column_id', $boardColumnId), ); } }