diff --git a/assets/controllers/chart_controller.js b/assets/controllers/chart_controller.js new file mode 100644 index 0000000..13de956 --- /dev/null +++ b/assets/controllers/chart_controller.js @@ -0,0 +1,73 @@ +import { Controller } from '@hotwired/stimulus' +import { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js' + +Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend) + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = ['canvas'] + static values = { + labels: Array, + data: Array, + title: String, + color: { type: String, default: '#004b7a' }, + maxY: { type: Number, default: 5 }, + } + + connect() { + this.chart = new Chart(this.canvasTarget, { + type: 'bar', + data: { + labels: this.labelsValue, + datasets: [{ + label: 'Frage', + data: this.dataValue, + backgroundColor: this.colorValue, + borderWidth: 0, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + callbacks: { + label: function(context) { + return context.parsed.y.toFixed(2) + } + } + }, + title: { + display: !!this.titleValue, + text: this.titleValue, + } + }, + scales: { + y: { + beginAtZero: false, + min: 1, + max: this.maxYValue, + ticks: { + stepSize: 1, + } + }, + x: { + grid: { + display: false, + } + } + } + } + }) + } + + disconnect() { + if (this.chart) { + this.chart.destroy() + } + } +} diff --git a/migrations/Version20251223131716.php b/migrations/Version20251223131716.php new file mode 100644 index 0000000..4a1f23e --- /dev/null +++ b/migrations/Version20251223131716.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE feedback ADD feedback_set_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE feedback ADD CONSTRAINT FK_D22944582DB73956 FOREIGN KEY (feedback_set_id) REFERENCES feedback_set (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_D22944582DB73956 ON feedback (feedback_set_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE feedback DROP FOREIGN KEY FK_D22944582DB73956'); + $this->addSql('DROP INDEX IDX_D22944582DB73956 ON feedback'); + $this->addSql('ALTER TABLE feedback DROP feedback_set_id'); + } +} diff --git a/package-lock.json b/package-lock.json index 8024e98..5753d69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@tailwindcss/typography": "^0.5.10", "@trevoreyre/autocomplete-js": "^2.4.1", "autoprefixer": "^10.4.15", + "chart.js": "^4.5.1", "core-js": "^3.23.0", "dropzone": "^6.0.0-beta.2", "file-loader": "^6.2.0", @@ -1868,6 +1869,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -3241,6 +3248,18 @@ "node": ">=0.8.0" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", diff --git a/package.json b/package.json index 9282d45..844d260 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@tailwindcss/typography": "^0.5.10", "@trevoreyre/autocomplete-js": "^2.4.1", "autoprefixer": "^10.4.15", + "chart.js": "^4.5.1", "core-js": "^3.23.0", "dropzone": "^6.0.0-beta.2", "file-loader": "^6.2.0", diff --git a/src/Command/PopulateFeedbackSetCommand.php b/src/Command/PopulateFeedbackSetCommand.php new file mode 100644 index 0000000..5aa1799 --- /dev/null +++ b/src/Command/PopulateFeedbackSetCommand.php @@ -0,0 +1,200 @@ +addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Run without persisting changes to the database' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $isDryRun = true === $input->getOption('dry-run'); + + if ($isDryRun) { + $io->note('Running in dry-run mode. No changes will be persisted.'); + } + + // Build lookup table: question texts -> FeedbackSet + $feedbackSets = $this->feedbackSetRepository->findAll(); + $lookup = []; + + foreach ($feedbackSets as $feedbackSet) { + $key = $this->buildQuestionKey($feedbackSet->getRatings()); + $lookup[$key] = $feedbackSet; + $io->writeln(sprintf('Registered FeedbackSet #%d "%s" with %d questions', $feedbackSet->getId(), $feedbackSet->getName(), count($feedbackSet->getRatings()))); + } + + // Find feedbacks without feedbackSet + $qb = $this->feedbackRepository->createQueryBuilder('f'); + $feedbacks = $qb + ->where($qb->expr()->isNull('f.feedbackSet')) + ->andWhere($qb->expr()->isNotNull('f.ratings')) + ->getQuery() + ->getResult() + ; + + $totalCount = count($feedbacks); + + if (0 === $totalCount) { + $io->success('No feedbacks found that need feedbackSet assignment.'); + + return Command::SUCCESS; + } + + $io->info(sprintf('Found %d feedback(s) without feedbackSet.', $totalCount)); + + $matchedCount = 0; + $fallbackCount = 0; + $unmatchedCount = 0; + + /** @var Feedback $feedback */ + foreach ($feedbacks as $feedback) { + $feedbackSet = $this->matchByQuestions($feedback, $lookup); + + if (null !== $feedbackSet) { + $feedback->setFeedbackSet($feedbackSet); + ++$matchedCount; + + if ($output->isVerbose()) { + $io->writeln(sprintf( + 'Matched feedback #%d to FeedbackSet "%s" by questions', + $feedback->getId(), + $feedbackSet->getName() + )); + } + + continue; + } + + // Fallback: try via Assignment -> JobProfile -> FeedbackSet + $feedbackSet = $this->matchByAssignment($feedback); + + if (null !== $feedbackSet) { + $feedback->setFeedbackSet($feedbackSet); + ++$fallbackCount; + + if ($output->isVerbose()) { + $io->writeln(sprintf( + 'Matched feedback #%d to FeedbackSet "%s" via Assignment fallback', + $feedback->getId(), + $feedbackSet->getName() + )); + } + + continue; + } + + ++$unmatchedCount; + $io->warning(sprintf( + 'Could not match feedback #%d (no matching FeedbackSet found)', + $feedback->getId() + )); + } + + if (false === $isDryRun && ($matchedCount > 0 || $fallbackCount > 0)) { + $this->entityManager->flush(); + $io->success(sprintf( + 'Successfully updated %d feedback(s): %d by questions, %d by assignment fallback. %d unmatched.', + $matchedCount + $fallbackCount, + $matchedCount, + $fallbackCount, + $unmatchedCount + )); + } elseif ($isDryRun) { + $io->success(sprintf( + 'Dry-run complete. Would update %d feedback(s): %d by questions, %d by assignment fallback. %d unmatched.', + $matchedCount + $fallbackCount, + $matchedCount, + $fallbackCount, + $unmatchedCount + )); + } else { + $io->info(sprintf('No feedbacks updated. %d unmatched.', $unmatchedCount)); + } + + return Command::SUCCESS; + } + + /** + * @param array $ratings + */ + private function buildQuestionKey(array $ratings): string + { + // For FeedbackSet, ratings is array of strings + if (isset($ratings[0]) && is_string($ratings[0])) { + return implode('|', $ratings); + } + + // For Feedback, ratings is array of {rating, mark} + $questions = array_map(fn (array $r): string => $r['rating'], $ratings); + + return implode('|', $questions); + } + + /** + * @param array $lookup + */ + private function matchByQuestions(Feedback $feedback, array $lookup): ?\App\Entity\FeedbackSet + { + $ratings = $feedback->getRatings(); + + if (0 === count($ratings)) { + return null; + } + + $key = $this->buildQuestionKey($ratings); + + return $lookup[$key] ?? null; + } + + private function matchByAssignment(Feedback $feedback): ?\App\Entity\FeedbackSet + { + $assignment = $feedback->getAssignment(); + + if (null === $assignment) { + return null; + } + + $jobProfile = $assignment->getJobProfile(); + + if (null === $jobProfile) { + return null; + } + + return $jobProfile->getFeedbackSet(); + } +} diff --git a/src/Controller/Administrative/Statistics/FeedbackRatingsController.php b/src/Controller/Administrative/Statistics/FeedbackRatingsController.php new file mode 100644 index 0000000..27a1fc9 --- /dev/null +++ b/src/Controller/Administrative/Statistics/FeedbackRatingsController.php @@ -0,0 +1,72 @@ +filterHandler->getFilterSettings(); + + $form = $this->createForm(FeedbackStatisticsFilterType::class, $filterDto); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $filterDto = $this->filterHandler->handleRequest($form); + } + + $statistics = $this->feedbackRepository->getAverageRatingsByQuestionAndFeedbackSet( + $filterDto->getDateFrom(), + $filterDto->getDateTo(), + ); + + // Prepare chart data for each feedback set + $chartsData = []; + foreach ($statistics as $stat) { + $feedbackSet = $stat['feedbackSet']; + $questions = $feedbackSet->getRatings(); + $averages = $stat['averages']; + + // Build labels (1, 2, 3, ...) for x-axis + $labels = []; + $data = []; + foreach ($questions as $index => $question) { + $labels[] = (string) ($index + 1); + $data[] = $averages[$index] ?? 0.0; + } + + $chartsData[] = [ + 'feedbackSet' => $feedbackSet, + 'labels' => $labels, + 'data' => $data, + 'questions' => $questions, + 'count' => $stat['count'], + ]; + } + + return $this->render('administrative/statistics/feedback_ratings.html.twig', [ + 'form' => $form->createView(), + 'filterDto' => $filterDto, + 'chartsData' => $chartsData, + ]); + } +} diff --git a/src/Entity/Feedback.php b/src/Entity/Feedback.php index bbe3289..efebcbf 100644 --- a/src/Entity/Feedback.php +++ b/src/Entity/Feedback.php @@ -78,7 +78,8 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface #[ORM\Column(length: 255)] private ?string $author; - // Transient property for form only + #[ORM\ManyToOne] + #[ORM\JoinColumn(onDelete: 'SET NULL')] #[Assert\NotNull(message: 'Bitte auswählen', groups: ['admin'])] private ?FeedbackSet $feedbackSet = null; @@ -105,6 +106,7 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface ->setHotelBusProId($destination->getHotelBusProId()) ->setJobProfileName($jobProfile->getName()) ->setJobProfileCategory($jobProfile->getCategory()) + ->setFeedbackSet($jobProfile->getFeedbackSet()) ; return $instance; diff --git a/src/Form/FeedbackStatisticsFilterType.php b/src/Form/FeedbackStatisticsFilterType.php new file mode 100644 index 0000000..7ed5970 --- /dev/null +++ b/src/Form/FeedbackStatisticsFilterType.php @@ -0,0 +1,43 @@ +add('dateFrom', DatepickerType::class, [ + 'label' => 'Zeitraum von', + 'required' => false, + ]) + ->add('dateTo', DatepickerType::class, [ + 'label' => 'Zeitraum bis', + 'required' => false, + ]) + ->add('apply', SubmitType::class, [ + 'label' => 'filtern', + ]) + ->add('reset', SubmitType::class, [ + 'label' => 'reset', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'data_class' => FeedbackStatisticsFilterDto::class, + ]) + ; + } +} diff --git a/src/Form/TeamerJobProfileType.php b/src/Form/TeamerJobProfileType.php index b58ac8c..f41a5a6 100644 --- a/src/Form/TeamerJobProfileType.php +++ b/src/Form/TeamerJobProfileType.php @@ -4,11 +4,9 @@ namespace App\Form; use App\Entity\JobProfile; use App\Entity\Teamer; -use App\Entity\Training; use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index 94de511..d493a87 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -113,6 +113,22 @@ class AdminMenuBuilder extends AbstractMenuBuilder 'icon' => 'calendar', ], ]); + $statisticsMenu = $menu->addChild('Statistiken', [ + 'extras' => [ + 'icon' => 'chart', + ], + ]); + $statisticsMenu->addChild('Feedback-Bewertungen', [ + 'route' => 'app_administrative_statistics_feedback_ratings', + 'linkAttributes' => [ + 'title' => 'Feedback-Bewertungen', + ], + 'extras' => [ + 'routes' => [ + ['pattern' => '/^app_administrative_statistics_/'], + ], + ], + ]); $settingsMenu = $menu->addChild('Einstellungen', [ 'linkAttributes' => [ 'title' => 'Einstellungen', diff --git a/src/Menu/ManagerMenuBuilder.php b/src/Menu/ManagerMenuBuilder.php index 2b9dc78..efefaa1 100644 --- a/src/Menu/ManagerMenuBuilder.php +++ b/src/Menu/ManagerMenuBuilder.php @@ -94,6 +94,22 @@ class ManagerMenuBuilder extends AbstractMenuBuilder ], ], ]); + $statisticsMenu = $menu->addChild('Statistiken', [ + 'extras' => [ + 'icon' => 'chart', + ], + ]); + $statisticsMenu->addChild('Feedback-Bewertungen', [ + 'route' => 'app_administrative_statistics_feedback_ratings', + 'linkAttributes' => [ + 'title' => 'Feedback-Bewertungen', + ], + 'extras' => [ + 'routes' => [ + ['pattern' => '/^app_administrative_statistics_/'], + ], + ], + ]); $settingsMenu = $menu->addChild('Einstellungen', [ 'linkAttributes' => [ 'title' => 'Einstellungen', diff --git a/src/Model/DestinationDto.php b/src/Model/DestinationDto.php index 9d1fe39..e7b1f33 100644 --- a/src/Model/DestinationDto.php +++ b/src/Model/DestinationDto.php @@ -42,7 +42,6 @@ class DestinationDto ->setCountry($destination->getCountry()) ->setPickups($pickupIds) ->setCostUnit($destination->getCostUnit()); - ; return $instance; } diff --git a/src/Model/FeedbackStatisticsFilterDto.php b/src/Model/FeedbackStatisticsFilterDto.php new file mode 100644 index 0000000..a00368c --- /dev/null +++ b/src/Model/FeedbackStatisticsFilterDto.php @@ -0,0 +1,35 @@ +dateFrom; + } + + public function setDateFrom(?\DateTimeImmutable $dateFrom): static + { + $this->dateFrom = $dateFrom; + + return $this; + } + + public function getDateTo(): ?\DateTimeImmutable + { + return $this->dateTo; + } + + public function setDateTo(?\DateTimeImmutable $dateTo): static + { + $this->dateTo = $dateTo; + + return $this; + } +} diff --git a/src/Repository/FeedbackRepository.php b/src/Repository/FeedbackRepository.php index b791fec..1b89087 100644 --- a/src/Repository/FeedbackRepository.php +++ b/src/Repository/FeedbackRepository.php @@ -3,6 +3,7 @@ namespace App\Repository; use App\Entity\Feedback; +use App\Entity\FeedbackSet; use App\Entity\Teamer; use App\Model\FeedbackFilterDto; use App\Repository\Traits\QueryHelperTrait; @@ -116,4 +117,103 @@ class FeedbackRepository extends ServiceEntityRepository ->getResult() ; } + + /** + * Returns average ratings per question index grouped by FeedbackSet. + * + * @return array, + * count: int + * }> + */ + public function getAverageRatingsByQuestionAndFeedbackSet( + ?\DateTimeImmutable $dateFrom = null, + ?\DateTimeImmutable $dateTo = null, + ): array { + $qb = $this->createQueryBuilder('f'); + + $qb + ->select('f.ratings', 'IDENTITY(f.feedbackSet) AS feedbackSetId') + ->where('f.status = :status') + ->andWhere('f.feedbackSet IS NOT NULL') + ->setParameter('status', Feedback::STATUS_PUBLISHED) + ; + + if (null !== $dateFrom) { + $qb + ->andWhere($qb->expr()->gte('f.assignmentDateFrom', ':dateFrom')) + ->setParameter('dateFrom', $dateFrom) + ; + } + + if (null !== $dateTo) { + $qb + ->andWhere($qb->expr()->lte('f.assignmentDateTo', ':dateTo')) + ->setParameter('dateTo', $dateTo) + ; + } + + $results = $qb->getQuery()->getResult(); + + // Group by feedbackSet and calculate averages per question index + $grouped = []; + + foreach ($results as $row) { + $feedbackSetId = $row['feedbackSetId']; + $ratings = $row['ratings']; + + if (false === isset($grouped[$feedbackSetId])) { + $grouped[$feedbackSetId] = [ + 'sums' => [], + 'counts' => [], + 'totalCount' => 0, + ]; + } + + foreach ($ratings as $index => $rating) { + if (false === isset($rating['mark'])) { + continue; + } + + $mark = (float) $rating['mark']; + + if (false === isset($grouped[$feedbackSetId]['sums'][$index])) { + $grouped[$feedbackSetId]['sums'][$index] = 0.0; + $grouped[$feedbackSetId]['counts'][$index] = 0; + } + + $grouped[$feedbackSetId]['sums'][$index] += $mark; + ++$grouped[$feedbackSetId]['counts'][$index]; + } + + ++$grouped[$feedbackSetId]['totalCount']; + } + + // Convert sums to averages and fetch FeedbackSet entities + $feedbackSetRepository = $this->getEntityManager()->getRepository(FeedbackSet::class); + $output = []; + + foreach ($grouped as $feedbackSetId => $data) { + $feedbackSet = $feedbackSetRepository->find($feedbackSetId); + + if (null === $feedbackSet) { + continue; + } + + $averages = []; + foreach ($data['sums'] as $index => $sum) { + $count = $data['counts'][$index]; + $averages[$index] = $count > 0 ? round($sum / $count, 2) : 0.0; + } + + $output[] = [ + 'feedbackSet' => $feedbackSet, + 'averages' => $averages, + 'count' => $data['totalCount'], + ]; + } + + return $output; + } } diff --git a/src/Service/Common/FeedbackStatisticsFilterHandler.php b/src/Service/Common/FeedbackStatisticsFilterHandler.php new file mode 100644 index 0000000..790f375 --- /dev/null +++ b/src/Service/Common/FeedbackStatisticsFilterHandler.php @@ -0,0 +1,41 @@ +modelClass(); + + if (null === $data = $this->getSession()->get($this->namespace)) { + return $filterDto; + } + + if (isset($data['date_from'])) { + $filterDto->setDateFrom($data['date_from']); + } + if (isset($data['date_to'])) { + $filterDto->setDateTo($data['date_to']); + } + + return $filterDto; + } + + protected function saveFilterSettings(AbstractFilterDto $filterDto): void + { + /* @var FeedbackStatisticsFilterDto $filterDto */ + $this->getSession()->set($this->namespace, [ + 'date_from' => $filterDto->getDateFrom(), + 'date_to' => $filterDto->getDateTo(), + ]); + } +} diff --git a/templates/administrative/statistics/feedback_ratings.html.twig b/templates/administrative/statistics/feedback_ratings.html.twig new file mode 100644 index 0000000..2dc825b --- /dev/null +++ b/templates/administrative/statistics/feedback_ratings.html.twig @@ -0,0 +1,91 @@ +{% extends 'administrative/layout.html.twig' %} + +{% block title %}Feedback-Statistik{% endblock %} + +{% block content %} +
+

+ Feedback-Statistik +

+
+ +
+ {{ form_start(form, { attr: { class: 'flex flex-wrap items-end gap-4' } }) }} +
+ {{ form_row(form.dateFrom) }} +
+
+ {{ form_row(form.dateTo) }} +
+
+ {{ form_widget(form.apply, { attr: { class: 'btn btn--small' } }) }} + {{ form_widget(form.reset, { attr: { class: 'btn btn--small btn--secondary' } }) }} +
+ {{ form_end(form) }} +
+ + {% if filterDto.active %} +

+ Filter aktiv: + {% if filterDto.dateFrom %}von {{ filterDto.dateFrom|date('d.m.Y') }}{% endif %} + {% if filterDto.dateTo %}bis {{ filterDto.dateTo|date('d.m.Y') }}{% endif %} +

+ {% endif %} + + {% if chartsData is empty %} +
+ Keine Feedback-Daten im ausgewählten Zeitraum vorhanden. +
+ {% else %} +
+ {% for chartData in chartsData %} +
+
+

{{ chartData.feedbackSet.name }}

+

{{ chartData.count }} Feedbacks

+
+
+
+

Fragen

+
    + {% for question in chartData.questions %} +
  1. + {{ loop.index }}. + {{ question }} +
  2. + {% endfor %} +
+
+
+
+ +
+
+ + + + {% for label in chartData.labels %} + + {% endfor %} + + + + + {% for value in chartData.data %} + + {% endfor %} + + +
{{ label }}
{{ value|number_format(2, ',', '.') }}
+
+
+
+
+ {% endfor %} +
+ {% endif %} +{% endblock %}