diff --git a/.ruby-version b/.ruby-version index c877459..ff365e0 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.1.3 +3.1.3 diff --git a/Gemfile b/Gemfile index 6473e19..bc34f1f 100644 --- a/Gemfile +++ b/Gemfile @@ -33,8 +33,6 @@ gem "acts_as_list" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" -gem 'concurrent-ruby', '1.3.4' - # Use Redis adapter to run Action Cable in production # gem "redis", "~> 4.0" diff --git a/Gemfile.lock b/Gemfile.lock index 770fab4..fe8f982 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -251,7 +251,6 @@ DEPENDENCIES acts_as_list bootsnap capybara - concurrent-ruby (= 1.3.4) cssbundling-rails debug foreman (~> 0.88.1) diff --git a/app/controllers/branch_analytics_controller.rb b/app/controllers/branch_analytics_controller.rb new file mode 100644 index 0000000..a2e98bd --- /dev/null +++ b/app/controllers/branch_analytics_controller.rb @@ -0,0 +1,247 @@ +class BranchAnalyticsController < ApplicationController + before_action :set_survey + + def show + @show_deleted = params[:show_deleted] == 'true' + @analytics = { + overview: survey_overview, + by_branch: branch_response_summary, + question_analysis: question_breakdown, + completion_stats: completion_statistics, + charts: chart_data, + deletion_summary: deletion_summary + } + @chart_data_json = chart_data.to_json + end + + private + + def set_survey + @survey = Survey.find(params[:survey_id]) + end + + def survey_overview + { + total_responses: responses_scope.count, + total_branches: branches_scope.count, + total_questions: questions_scope.count, + response_rate: calculate_overall_response_rate, + deleted_responses: @survey.responses.only_deleted.count, + deleted_branches: @survey.survey_branches.only_deleted.count, + deleted_questions: @survey.questions.only_deleted.count + } + end + + def branch_response_summary + survey_branches_with_associations.map do |branch| + branch_responses = responses_for_branch(branch) + + { + role: branch.role.display_name, + branch_id: branch.id, + total_responses: branch_responses.count, + unique_respondents: branch_responses.distinct.count, + questions_count: branch.branch_questions.count, + avg_response_length: calculate_avg_response_length(branch_responses), + completion_rate: calculate_branch_completion_rate(branch), + response_distribution: response_type_distribution(branch) + } + end + end + + def question_breakdown + questions_scope.includes(:responses, :branch_questions).map do |question| + { + question_id: question.id, + content: question.content, + question_type: question.question_type, + total_responses: question.responses.where(survey_id: @survey.id).count, + branches: branches_scope.includes(:role).map do |branch| + branch_responses = question.responses.where(survey_id: @survey.id, survey_branch: branch) + { + role: branch.role.display_name, + response_count: branch_responses.count, + sample_responses: branch_responses.limit(3).pluck(:value) + } + end + } + end + end + + def completion_statistics + { + response_timeline: response_timeline_data, + most_active_branch: most_active_branch, + least_active_branch: least_active_branch, + average_responses_per_branch: average_responses_per_branch + } + end + + def deletion_summary + { + deleted_responses_by_date: deleted_responses_timeline, + deleted_questions_by_type: deleted_questions_by_type, + deleted_branches_by_role: deleted_branches_by_role, + recent_deletions: recent_deletions + } + end + + def calculate_overall_response_rate + total_possible = branches_scope.joins(:branch_questions).count + return 0 if total_possible.zero? + + (responses_scope.count.to_f / total_possible * 100).round(2) + end + + def calculate_avg_response_length(responses) + text_responses = responses.where.not(value: [nil, '']) + return 0 if text_responses.empty? + + (text_responses.sum { |r| r.value.to_s.length } / text_responses.count.to_f).round(2) + end + + def calculate_branch_completion_rate(branch) + total_questions = branch.branch_questions.count + return 0 if total_questions.zero? + + branch_responses = responses_for_branch(branch) + + (branch_responses.count.to_f / total_questions * 100).round(2) + end + + def response_type_distribution(branch) + responses_for_branch(branch) + .joins(:question) + .group('questions.question_type') + .count + end + + def response_timeline_data + responses_scope + .where('created_at >= ?', 30.days.ago) + .group("DATE(created_at)") + .order("DATE(created_at)") + .count + end + + def most_active_branch + branch_data = branch_response_summary + return nil if branch_data.empty? + + branch_data.max_by { |b| b[:total_responses] } + end + + def least_active_branch + branch_data = branch_response_summary + return nil if branch_data.empty? + + branch_data.min_by { |b| b[:total_responses] } + end + + def average_responses_per_branch + branch_data = branch_response_summary + return 0 if branch_data.empty? + + (branch_data.sum { |b| b[:total_responses] } / branch_data.count.to_f).round(2) + end + + def chart_data + { + responses_by_branch: responses_by_branch_chart, + completion_rates: completion_rates_chart, + response_timeline: response_timeline_chart, + question_types: question_types_chart, + daily_responses: daily_responses_chart + } + end + + def responses_by_branch_chart + survey_branches_with_associations.map do |branch| + [branch.role.display_name, responses_for_branch(branch).count] + end.to_h + end + + def completion_rates_chart + survey_branches_with_associations.map do |branch| + completion_rate = calculate_branch_completion_rate(branch).round(1) + [branch.role.display_name, completion_rate] + end.to_h + end + + def response_timeline_chart + responses_by_date_range(14) + end + + def question_types_chart + questions_scope.group(:question_type).count.transform_keys(&:humanize) + end + + def daily_responses_chart + responses_by_date_range(7) + end + + # Helper methods for DRY code + def survey_branches_with_associations + @survey_branches_with_associations ||= branches_scope.includes(:role, :branch_questions) + end + + def responses_scope + @show_deleted ? @survey.responses.with_deleted : @survey.responses + end + + def branches_scope + @show_deleted ? @survey.survey_branches.with_deleted : @survey.survey_branches + end + + def questions_scope + @show_deleted ? @survey.questions.with_deleted : @survey.questions + end + + def responses_for_branch(branch) + responses_scope.where(survey_branch: branch) + end + + def responses_by_date_range(days) + end_date = Date.current + start_date = end_date - (days - 1).days + + # Get responses within date range + responses_in_range = responses_scope.where(created_at: start_date.beginning_of_day..end_date.end_of_day) + + # Group by date and count + grouped_responses = responses_in_range.group("DATE(created_at)").count + + # Fill in missing dates with 0 count + result = {} + (start_date..end_date).each do |date| + date_key = date.strftime("%Y-%m-%d") + result[date_key] = grouped_responses[date] || 0 + end + + result + end + + def deleted_responses_timeline + @survey.responses.only_deleted + .where('deleted_at >= ?', 30.days.ago) + .group("DATE(deleted_at)") + .order("DATE(deleted_at)") + .count + end + + def deleted_questions_by_type + @survey.questions.only_deleted.group(:question_type).count.transform_keys(&:humanize) + end + + def deleted_branches_by_role + @survey.survey_branches.only_deleted.joins(:role).group('roles.name').count + end + + def recent_deletions + { + responses: @survey.responses.only_deleted.order(deleted_at: :desc).limit(5).pluck(:deleted_at, :value), + questions: @survey.questions.only_deleted.order(deleted_at: :desc).limit(5).pluck(:deleted_at, :content), + branches: @survey.survey_branches.only_deleted.includes(:role).order(deleted_at: :desc).limit(5).map { |b| [b.deleted_at, b.role.display_name] } + } + end +end \ No newline at end of file diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index b7dbcce..e106faa 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -1,16 +1,50 @@ class QuestionsController < ApplicationController before_action :set_survey - before_action :set_question, only: [:edit, :update, :destroy] + before_action :set_question, only: [:edit, :update, :destroy, :restore] + before_action :set_role, only: [:new, :create, :edit, :update, :destroy] + before_action :set_roles, only: [:new, :create, :edit, :update] + + def index + @questions = params[:show_deleted] == 'true' ? @survey.questions.with_deleted : @survey.questions + @questions = @questions.distinct + + if params[:survey_branch_id].present? + @questions = @questions.joins(:branch_questions) + .where(branch_questions: { survey_branch_id: params[:survey_branch_id] }) + .distinct + elsif params[:role_id].present? + @questions = @questions.joins(branch_questions: { survey_branch: :role }) + .where(roles: { id: params[:role_id] }) + .distinct + end + + @questions = @questions.order(:position) if @questions.column_names.include?("position") + + respond_to do |format| + format.json do + render json: @questions.as_json( + only: [:id, :content, :question_type, :required], + methods: [:deleted?] + ).map do |q| + q['deleted_at'] = @questions.find(q['id']).deleted_at&.strftime('%B %d, %Y') if q['deleted?'] + q + end + end + format.html { redirect_to survey_path(@survey) } + end + end def new - @question = @survey.questions.new + @question = Question.new end def create - @question = @survey.questions.new(question_params) + @question = Question.new(question_params.except(:survey_branch_id)) process_options if @question.save + branch = @survey.survey_branches.find_by(id: params[:question][:survey_branch_id]) + BranchQuestion.create!(survey_branch: branch, question: @question) if branch redirect_to survey_path(@survey), notice: 'Question was successfully created.' else render :new, status: :unprocessable_entity @@ -35,7 +69,18 @@ def update def destroy @question.destroy - redirect_to survey_path(@survey), notice: 'Question was successfully destroyed.' + respond_to do |format| + format.json { head :no_content } + format.html { redirect_to survey_path(@survey), notice: 'Question was successfully soft deleted. It can be restored if needed.' } + end + end + + def restore + @question.restore + respond_to do |format| + format.json { head :no_content } + format.html { redirect_to survey_path(@survey), notice: 'Question was successfully restored.' } + end end private @@ -45,11 +90,23 @@ def set_survey end def set_question - @question = @survey.questions.find(params[:id]) + if action_name == 'restore' + @question = @survey.questions.with_deleted.find(params[:id]) + else + @question = @survey.questions.find(params[:id]) + end + end + + def set_role + @role = Role.find_by(name: params[:role].downcase) if params[:role].present? + end + + def set_roles + @roles = Role.all end def question_params - params.require(:question).permit(:content, :question_type, :position, :required, options: []) + params.require(:question).permit(:content, :question_type, :position, :required, options: [], survey_branch_ids: []) end def process_options diff --git a/app/controllers/responses_controller.rb b/app/controllers/responses_controller.rb index 5abfa41..ba26acc 100644 --- a/app/controllers/responses_controller.rb +++ b/app/controllers/responses_controller.rb @@ -17,6 +17,6 @@ def create private def response_params - params.require(:response).permit(:survey_id, :question_id, :value) + params.require(:response).permit(:survey_id, :question_id, :value) end end diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb new file mode 100644 index 0000000..8ee5cb6 --- /dev/null +++ b/app/controllers/roles_controller.rb @@ -0,0 +1,64 @@ +class RolesController < ApplicationController + before_action :set_role, only: [:show, :edit, :update, :destroy, :restore] + + def index + @roles = params[:show_deleted] == 'true' ? Role.with_deleted : Role.all + @show_deleted = params[:show_deleted] == 'true' + end + + def deleted + @roles = Role.only_deleted + render :index + end + + def show + end + + def new + @role = Role.new + end + + def create + @role = Role.new(role_params) + if @role.save + redirect_to @role, notice: 'Role was successfully created.' + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @role.update(role_params) + redirect_to @role, notice: 'Role was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @role.destroy + redirect_to roles_url, notice: 'Role was successfully soft deleted. It can be restored if needed.' + end + + def restore + @role.restore + redirect_to roles_url, notice: 'Role was successfully restored.' + end + + private + + def set_role + if action_name == 'restore' + @role = Role.with_deleted.find(params[:id]) + else + @role = Role.find(params[:id]) + end + end + + def role_params + params.require(:role).permit(:name) + end +end \ No newline at end of file diff --git a/app/controllers/surveys_controller.rb b/app/controllers/surveys_controller.rb index a472b37..d7d0629 100644 --- a/app/controllers/surveys_controller.rb +++ b/app/controllers/surveys_controller.rb @@ -1,11 +1,20 @@ class SurveysController < ApplicationController - before_action :set_survey, only: [:show, :edit, :update, :destroy, :take, :submit] + before_action :set_survey, only: [:show, :edit, :update, :destroy, :take, :submit, :restore] def index - @surveys = Survey.all + @surveys = params[:show_deleted] == 'true' ? Survey.with_deleted : Survey.all + @show_deleted = params[:show_deleted] == 'true' + @roles = Role.all.map { |role| { id: role.id, name: role.display_name } } + end + + def deleted + @surveys = Survey.only_deleted + render :index end def show + @roles = Role.all.map { |role| { id: role.id, name: role.display_name } } + @all_roles = Role.all end def new @@ -16,9 +25,19 @@ def create @survey = Survey.new(survey_params) if @survey.save - redirect_to @survey, notice: 'Survey was successfully created.' + # Create a SurveyBranch for each Role + Role.find_each do |role| + SurveyBranch.create!(survey: @survey, role: role, name: "#{role.name} Branch") + end + respond_to do |format| + format.html { redirect_to @survey, notice: 'Survey was successfully created.' } + format.json { render json: { message: 'Survey created successfully' } } + end else - render :new, status: :unprocessable_entity + respond_to do |format| + format.html { render :new, status: :unprocessable_entity } + format.json { render json: { errors: @survey.errors.full_messages }, status: :unprocessable_entity } + end end end @@ -27,41 +46,87 @@ def edit def update if @survey.update(survey_params) - redirect_to @survey, notice: 'Survey was successfully updated.' + respond_to do |format| + format.html { redirect_to @survey, notice: 'Survey was successfully updated.' } + format.json { render json: { success: true, redirect_url: survey_path(@survey) }, status: :ok } + end else - render :edit, status: :unprocessable_entity + respond_to do |format| + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: { errors: @survey.errors.full_messages }, status: :unprocessable_entity } + end end end - + def destroy @survey.destroy - redirect_to surveys_url, notice: 'Survey was successfully destroyed.' + redirect_to surveys_url, notice: 'Survey was successfully soft deleted. It can be restored if needed.' + end + + def restore + @survey.restore + redirect_to surveys_url, notice: 'Survey was successfully restored.' end def take - @questions = @survey.questions.order(:position) + @survey = Survey.find(params[:id]) + @role = Role.find_by(id: params[:role]) + + if @role.nil? + redirect_to survey_path(@survey), alert: 'Invalid role specified.' + return + end + + @branch = @survey.survey_branches.find_by(role: @role) + + if @branch.nil? + redirect_to survey_path(@survey), alert: 'No questions available for this role.' + return + end + + @questions = Question.joins(:branch_questions) + .where(branch_questions: { survey_branch_id: @branch.id }) + .order(:position) end def submit - if params[:responses].present? - params[:responses].each do |response_params| + responses_data = params[:response][:responses] + selected_role_id = params[:response][:role_id] || params[:role_id] # Check both locations + + if responses_data.present? && responses_data.any? + # Find the survey branch for the selected role + selected_role = Role.find_by(id: selected_role_id) + survey_branch = @survey.survey_branches.find_by(role: selected_role) if selected_role + + responses_data.each do |response_params| @survey.responses.create( question_id: response_params[:question_id], - value: response_params[:value], + value: response_params[:content], + survey_branch: survey_branch ) end - redirect_to surveys_path, notice: 'Thank you for completing the survey!' + respond_to do |format| + format.html { redirect_to surveys_path, notice: 'Thank you for completing the survey!' } + format.json { render json: { message: 'Thank you for completing the survey!' }, status: :created } + end else - redirect_to take_survey_path(@survey), alert: 'Please answer at least one question.' + respond_to do |format| + format.html { redirect_to take_survey_path(@survey), alert: 'Please answer at least one question.' } + format.json { render json: { error: 'Please answer at least one question.' }, status: :unprocessable_entity } + end end end - + private - + def set_survey - @survey = Survey.find(params[:id]) + if action_name == 'restore' + @survey = Survey.with_deleted.find(params[:id]) + else + @survey = Survey.find(params[:id]) + end end - + def survey_params params.require(:survey).permit(:title, :description) end diff --git a/app/javascript/components/AnalyticsCharts.jsx b/app/javascript/components/AnalyticsCharts.jsx new file mode 100644 index 0000000..17072f0 --- /dev/null +++ b/app/javascript/components/AnalyticsCharts.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import PieChart from './charts/PieChart'; +import BarChart from './charts/BarChart'; +import LineChart from './charts/LineChart'; + +const AnalyticsCharts = ({ chartData }) => { + if (!chartData) { + return
Loading charts...
; + } + + const { + responses_by_branch, + completion_rates, + response_timeline, + question_types, + daily_responses + } = chartData; + + return ( +
+ {/* Charts Section */} +
+ {/* Responses by Branch Chart */} +
+
+

Responses by Role

+

Total responses received per role

+
+
+ +
+
+ + {/* Completion Rates Chart */} +
+
+

Completion Rates by Role

+

Percentage of questions answered per role

+
+
+ +
+
+
+ + {/* Timeline Charts */} +
+ {/* Response Timeline */} +
+
+

Response Timeline

+

Responses received over the last 14 days

+
+
+ +
+
+ + {/* Question Types Distribution */} +
+
+

Question Types

+

Distribution of question types in this survey

+
+
+ +
+
+
+ + {/* Daily Activity Chart */} +
+
+

Daily Activity

+

Response activity over the last 7 days

+
+
+ +
+
+
+ ); +}; + +export default AnalyticsCharts; \ No newline at end of file diff --git a/app/javascript/components/QuestionList.jsx b/app/javascript/components/QuestionList.jsx index 6982a37..faecbf7 100644 --- a/app/javascript/components/QuestionList.jsx +++ b/app/javascript/components/QuestionList.jsx @@ -4,51 +4,136 @@ import { createRoot } from 'react-dom/client'; const QuestionList = (props) => { const [questions, setQuestions] = useState(props.questions || []); const [loading, setLoading] = useState(false); + const [showDeleted, setShowDeleted] = useState(false); - const fetchQuestions = async () => { + const fetchQuestions = async (roleId = "", includeDeleted = false) => { setLoading(true); try { - const response = await fetch(`/surveys/${props.surveyId}/questions.json`); - if (response.ok) { - const data = await response.json(); - setQuestions(data); - } - } catch (error) { - console.error('Error fetching questions:', error); + const params = new URLSearchParams(); + if (roleId) params.append('role_id', roleId); + if (includeDeleted) params.append('show_deleted', 'true'); + + const url = `/surveys/${props.surveyId}/questions.json` + (params.toString() ? `?${params.toString()}` : ""); + const res = await fetch(url, { headers: { "Accept": "application/json" } }); + if (!res.ok) throw new Error("Failed to load questions"); + const data = await res.json(); + setQuestions(data); + } catch (e) { + console.error("Error loading questions", e); } finally { setLoading(false); } }; useEffect(() => { - if (props.autoload && props.surveyId) { - fetchQuestions(); + fetchQuestions("", showDeleted); + + // Handle role filter + const selectId = props.filterSelectId; + if (selectId) { + const sel = document.getElementById(selectId); + if (sel) { + const handler = (e) => { + fetchQuestions(e.target.value || "", showDeleted); + }; + sel.addEventListener("change", handler); + return () => sel.removeEventListener("change", handler); + } } - }, [props.surveyId]); + }, [props.surveyId, showDeleted]); + + useEffect(() => { + // Handle tab filtering + const tabs = document.querySelectorAll('.question-filter-tab'); + const handleTabClick = (e) => { + const filter = e.target.dataset.filter; + const newShowDeleted = filter === 'all'; + + // Update tab styling + tabs.forEach(tab => { + if (tab === e.target) { + tab.className = 'border-indigo-500 text-indigo-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm question-filter-tab'; + } else { + tab.className = 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm question-filter-tab'; + } + }); + + setShowDeleted(newShowDeleted); + }; + + tabs.forEach(tab => { + tab.addEventListener('click', handleTabClick); + }); + + return () => { + tabs.forEach(tab => { + tab.removeEventListener('click', handleTabClick); + }); + }; + }, []); const handleDelete = async (questionId) => { if (!confirm('Are you sure you want to delete this question?')) { return; } - const csrfToken = document.querySelector('meta[name="csrf-token"]').content; + const csrfTokenElement = document.querySelector('meta[name="csrf-token"]'); + const csrfToken = csrfTokenElement ? csrfTokenElement.content : ''; try { const response = await fetch(`/surveys/${props.surveyId}/questions/${questionId}`, { method: 'DELETE', headers: { - 'X-CSRF-Token': csrfToken + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + 'Accept': 'application/json' } }); if (response.ok) { - setQuestions(questions.filter(q => q.id !== questionId)); + // Refresh the list to show updated status + const roleSelect = document.getElementById(props.filterSelectId); + const currentRoleId = roleSelect ? roleSelect.value : ""; + fetchQuestions(currentRoleId, showDeleted); + } else { + console.error('Failed to delete question:', response.status); } } catch (error) { console.error('Error deleting question:', error); } }; + const handleRestore = async (questionId) => { + if (!confirm('Are you sure you want to restore this question?')) { + return; + } + + const csrfTokenElement = document.querySelector('meta[name="csrf-token"]'); + const csrfToken = csrfTokenElement ? csrfTokenElement.content : ''; + + try { + const response = await fetch(`/surveys/${props.surveyId}/questions/${questionId}/restore`, { + method: 'PATCH', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + + if (response.ok) { + // Refresh the list to show updated status + const roleSelect = document.getElementById(props.filterSelectId); + const currentRoleId = roleSelect ? roleSelect.value : ""; + fetchQuestions(currentRoleId, showDeleted); + } else { + console.error('Failed to restore question:', response.status); + } + } catch (error) { + console.error('Error restoring question:', error); + } + }; + const getQuestionTypeLabel = (type) => { return type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); }; @@ -68,29 +153,50 @@ const QuestionList = (props) => { return (