diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index bb4bc3f..2be8f90 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -57,10 +57,31 @@ class QuizController extends AbstractController { $fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id); + // Create indexed lookup for quiz candidates by candidate ID + $quizCandidatesByCandidateId = []; + foreach ($fetchedQuiz->candidateData as $qc) { + $quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc; + } + + // Get given answers counts efficiently via database query + $givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz); + + // Pre-compute candidate data to avoid nested loops in template + $candidateData = []; + foreach ($season->candidates as $candidate) { + $candidateIdString = $candidate->id->toString(); + $candidateData[] = [ + 'candidate' => $candidate, + 'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null, + 'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0, + ]; + } + return $this->render('backoffice/quiz.html.twig', [ 'season' => $season, 'quiz' => $fetchedQuiz, 'questionErrors' => $fetchedQuiz->getQuestionErrors(), + 'candidateData' => $candidateData, 'activeTab' => 'overview', 'template' => 'backoffice/quiz/tab_overview.html.twig', ]); @@ -87,11 +108,48 @@ class QuizController extends AbstractController #[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[Route( - '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates', + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/candidates-list', + name: 'tvdt_backoffice_quiz_candidates_tab', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + )] + public function candidatesTab(Season $season, Quiz $quiz): Response + { + // Create indexed lookup for quiz candidates by candidate ID + $quizCandidatesByCandidateId = []; + foreach ($quiz->candidateData as $qc) { + $quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc; + } + + // Get given answers counts efficiently via database query + $givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz); + + // Pre-compute candidate data to avoid nested loops in template + $candidateData = []; + foreach ($season->candidates as $candidate) { + $candidateIdString = $candidate->id->toString(); + $candidateData[] = [ + 'candidate' => $candidate, + 'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null, + 'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0, + ]; + } + + return $this->render('backoffice/quiz.html.twig', [ + 'season' => $season, + 'quiz' => $quiz, + 'candidateData' => $candidateData, + 'activeTab' => 'candidates', + 'template' => 'backoffice/quiz/tab_candidates_list.html.twig', + ]); + } + + #[IsGranted(SeasonVoter::EDIT, subject: 'season')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/answer-mapping', name: 'tvdt_backoffice_quiz_candidates', requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], )] - public function candidates(Season $season, Quiz $quiz): Response + public function answerMapping(Season $season, Quiz $quiz): Response { $fetchedQuiz = $this->quizRepository->fetchWithQuestions($quiz->id); \assert($fetchedQuiz->questions->count() > 0); @@ -119,7 +177,7 @@ class QuizController extends AbstractController 'quiz' => $quiz, 'question' => $question, 'candidates' => $season->candidates, - 'activeTab' => 'candidates', + 'activeTab' => 'answers', 'template' => 'backoffice/quiz/tab_candidates.html.twig', ]); } @@ -278,6 +336,6 @@ class QuizController extends AbstractController $this->addFlash('success', $this->translator->trans('Candidate status updated')); - return $this->redirectToRoute('tvdt_backoffice_quiz_overview', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id, '_fragment' => 'candidates']); + return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); } } diff --git a/src/Entity/Quiz.php b/src/Entity/Quiz.php index 404d095..a4d2f5e 100644 --- a/src/Entity/Quiz.php +++ b/src/Entity/Quiz.php @@ -90,8 +90,18 @@ class Quiz } } + // Pre-compute active candidates once for all questions + $activeCandidates = []; + if ($hasCandidateRelations) { + foreach ($this->candidateData as $quizCandidate) { + if ($quizCandidate->active) { + $activeCandidates[] = $quizCandidate->candidate; + } + } + } + foreach ($this->questions as $question) { - $error = $this->getQuestionError($question, $hasCandidateRelations); + $error = $this->getQuestionError($question, $hasCandidateRelations, $activeCandidates); if (null !== $error) { $errors[$question->id->toString()] = $error; } @@ -100,7 +110,10 @@ class Quiz return $errors; } - private function getQuestionError(Question $question, bool $hasCandidateRelations): ?string + /** + * @param list $activeCandidates + */ + private function getQuestionError(Question $question, bool $hasCandidateRelations, array $activeCandidates): ?string { if (0 === \count($question->answers)) { return 'This question has no answers'; @@ -118,13 +131,6 @@ class Quiz // Only validate candidate-answer relations if at least one exists in the quiz if ($hasCandidateRelations) { - // Get only active candidates for this quiz - $activeCandidates = []; - foreach ($this->candidateData as $quizCandidate) { - if ($quizCandidate->active) { - $activeCandidates[] = $quizCandidate->candidate; - } - } $candidateCounts = []; diff --git a/src/Repository/QuizRepository.php b/src/Repository/QuizRepository.php index cb0af3b..9a89c00 100644 --- a/src/Repository/QuizRepository.php +++ b/src/Repository/QuizRepository.php @@ -128,15 +128,40 @@ class QuizRepository extends ServiceEntityRepository public function fetchWithQuestionsAndCandidates(Uuid $id): Quiz { return $this->getEntityManager()->createQuery(<<setParameter('id', $id)->getSingleResult(); } + + /** + * Get given answers count per candidate for a quiz. + * + * @return array Array with candidate ID as key and count as value + */ + public function getGivenAnswersCountPerCandidate(Quiz $quiz): array + { + $results = $this->getEntityManager()->createQuery(<<setParameter('quiz', $quiz) + ->setParameter('season', $quiz->season) + ->getResult(); + + $counts = []; + foreach ($results as $row) { + $counts[$row['candidateId']->toString()] = (int) $row['answerCount']; + } + + return $counts; + } } diff --git a/templates/backoffice/quiz.html.twig b/templates/backoffice/quiz.html.twig index e1d99fb..bbb9fa4 100644 --- a/templates/backoffice/quiz.html.twig +++ b/templates/backoffice/quiz.html.twig @@ -15,8 +15,9 @@ {% block body %} {% set tabs = [ {id: 'overview', label: 'Overview'|trans, route: 'tvdt_backoffice_quiz_overview'}, + {id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_quiz_candidates_tab'}, {id: 'result', label: 'Results & Elimination'|trans, route: 'tvdt_backoffice_quiz_result'}, - {id: 'candidates', label: 'Candidates'|trans, route: 'tvdt_backoffice_quiz_candidates'}, + {id: 'answers', label: 'Answer Mapping'|trans, route: 'tvdt_backoffice_quiz_candidates'}, ] %}

{{ 'Quiz'|trans }}: {{ season.name }} - {{ quiz.name }}

diff --git a/templates/backoffice/quiz/tab_candidates_list.html.twig b/templates/backoffice/quiz/tab_candidates_list.html.twig new file mode 100644 index 0000000..6c3ba98 --- /dev/null +++ b/templates/backoffice/quiz/tab_candidates_list.html.twig @@ -0,0 +1,50 @@ +

{{ 'Candidates'|trans }}

+ + + + + + + + + + + {% for data in candidateData %} + {% set candidate = data.candidate %} + {% set quizCandidate = data.quizCandidate %} + {% set givenAnswersCount = data.givenAnswersCount %} + + + + + + + + {% endfor %} + +
{{ 'Name'|trans }}{{ 'Quiz Status'|trans }}{{ 'Candidate Status'|trans }}{{ 'Actions'|trans }}
{{ candidate.name }} + {% if quizCandidate and quizCandidate.started %} + {% if givenAnswersCount >= quiz.questions|length %} + {{ 'Completed'|trans }} + {% else %} + {{ 'In Progress'|trans }} ({{ givenAnswersCount }}/{{ quiz.questions|length }}) + {% endif %} + {% else %} + {{ 'Not Started'|trans }} + {% endif %} + + {% if quizCandidate == null or quizCandidate.active %} + {{ 'Active'|trans }} + {% else %} + {{ 'Inactive'|trans }} + {% endif %} + + + {% if quizCandidate == null or quizCandidate.active %} + {{ 'Deactivate'|trans }} + {% else %} + {{ 'Activate'|trans }} + {% endif %} + +
diff --git a/templates/backoffice/quiz/tab_overview.html.twig b/templates/backoffice/quiz/tab_overview.html.twig index 0cb01a2..bb2f1e5 100644 --- a/templates/backoffice/quiz/tab_overview.html.twig +++ b/templates/backoffice/quiz/tab_overview.html.twig @@ -56,67 +56,6 @@ {% endfor %} -

{{ 'Candidates'|trans }}

- - - - - - - - - - - {% for candidate in season.candidates %} - {% set quizCandidate = null %} - {% for qc in quiz.candidateData %} - {% if qc.candidate.id.toString == candidate.id.toString %} - {% set quizCandidate = qc %} - {% endif %} - {% endfor %} - - {% set givenAnswersCount = 0 %} - {% for givenAnswer in candidate.givenAnswers %} - {% if givenAnswer.quiz.id.toString == quiz.id.toString %} - {% set givenAnswersCount = givenAnswersCount + 1 %} - {% endif %} - {% endfor %} - - - - - - - - {% endfor %} - -
{{ 'Name'|trans }}{{ 'Quiz Status'|trans }}{{ 'Candidate Status'|trans }}{{ 'Actions'|trans }}
{{ candidate.name }} - {% if quizCandidate and quizCandidate.started %} - {% if givenAnswersCount >= quiz.questions|length %} - {{ 'Completed'|trans }} - {% else %} - {{ 'In Progress'|trans }} ({{ givenAnswersCount }}/{{ quiz.questions|length }}) - {% endif %} - {% else %} - {{ 'Not Started'|trans }} - {% endif %} - - {% if quizCandidate == null or quizCandidate.active %} - {{ 'Active'|trans }} - {% else %} - {{ 'Inactive'|trans }} - {% endif %} - - - {% if quizCandidate == null or quizCandidate.active %} - {{ 'Deactivate'|trans }} - {% else %} - {{ 'Activate'|trans }} - {% endif %} - -
- {# Modal Clear #}