feat: feedback statistics
addresses #869bgtz2d
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251223131716 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add feedback_set_id relation to feedback table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+19
@@ -16,6 +16,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@trevoreyre/autocomplete-js": "^2.4.1",
|
"@trevoreyre/autocomplete-js": "^2.4.1",
|
||||||
"autoprefixer": "^10.4.15",
|
"autoprefixer": "^10.4.15",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"core-js": "^3.23.0",
|
"core-js": "^3.23.0",
|
||||||
"dropzone": "^6.0.0-beta.2",
|
"dropzone": "^6.0.0-beta.2",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
@@ -1868,6 +1869,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@leichtgewicht/ip-codec": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||||
@@ -3241,6 +3248,18 @@
|
|||||||
"node": ">=0.8.0"
|
"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": {
|
"node_modules/cheerio": {
|
||||||
"version": "1.0.0-rc.12",
|
"version": "1.0.0-rc.12",
|
||||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@trevoreyre/autocomplete-js": "^2.4.1",
|
"@trevoreyre/autocomplete-js": "^2.4.1",
|
||||||
"autoprefixer": "^10.4.15",
|
"autoprefixer": "^10.4.15",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"core-js": "^3.23.0",
|
"core-js": "^3.23.0",
|
||||||
"dropzone": "^6.0.0-beta.2",
|
"dropzone": "^6.0.0-beta.2",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Feedback;
|
||||||
|
use App\Repository\FeedbackRepository;
|
||||||
|
use App\Repository\FeedbackSetRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:feedback:populate-feedback-set',
|
||||||
|
description: 'One-time command to populate missing feedbackSet for existing feedbacks',
|
||||||
|
)]
|
||||||
|
class PopulateFeedbackSetCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly FeedbackRepository $feedbackRepository,
|
||||||
|
private readonly FeedbackSetRepository $feedbackSetRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->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<int, array{rating: string, mark: int}> $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<string, \App\Entity\FeedbackSet> $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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Administrative\Statistics;
|
||||||
|
|
||||||
|
use App\Form\FeedbackStatisticsFilterType;
|
||||||
|
use App\Repository\FeedbackRepository;
|
||||||
|
use App\Service\Common\FeedbackStatisticsFilterHandler;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
class FeedbackRatingsController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly FeedbackStatisticsFilterHandler $filterHandler,
|
||||||
|
private readonly FeedbackRepository $feedbackRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/administrative/statistics/feedback-ratings', name: 'app_administrative_statistics_feedback_ratings')]
|
||||||
|
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$filterDto = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,8 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
|
|||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $author;
|
private ?string $author;
|
||||||
|
|
||||||
// Transient property for form only
|
#[ORM\ManyToOne]
|
||||||
|
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||||
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['admin'])]
|
#[Assert\NotNull(message: 'Bitte auswählen', groups: ['admin'])]
|
||||||
private ?FeedbackSet $feedbackSet = null;
|
private ?FeedbackSet $feedbackSet = null;
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
|
|||||||
->setHotelBusProId($destination->getHotelBusProId())
|
->setHotelBusProId($destination->getHotelBusProId())
|
||||||
->setJobProfileName($jobProfile->getName())
|
->setJobProfileName($jobProfile->getName())
|
||||||
->setJobProfileCategory($jobProfile->getCategory())
|
->setJobProfileCategory($jobProfile->getCategory())
|
||||||
|
->setFeedbackSet($jobProfile->getFeedbackSet())
|
||||||
;
|
;
|
||||||
|
|
||||||
return $instance;
|
return $instance;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Model\FeedbackStatisticsFilterDto;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class FeedbackStatisticsFilterType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->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,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,9 @@ namespace App\Form;
|
|||||||
|
|
||||||
use App\Entity\JobProfile;
|
use App\Entity\JobProfile;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Entity\Training;
|
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
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\Extension\Core\Type\TextareaType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|||||||
@@ -113,6 +113,22 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
'icon' => 'calendar',
|
'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', [
|
$settingsMenu = $menu->addChild('Einstellungen', [
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Einstellungen',
|
'title' => 'Einstellungen',
|
||||||
|
|||||||
@@ -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', [
|
$settingsMenu = $menu->addChild('Einstellungen', [
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Einstellungen',
|
'title' => 'Einstellungen',
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class DestinationDto
|
|||||||
->setCountry($destination->getCountry())
|
->setCountry($destination->getCountry())
|
||||||
->setPickups($pickupIds)
|
->setPickups($pickupIds)
|
||||||
->setCostUnit($destination->getCostUnit());
|
->setCostUnit($destination->getCostUnit());
|
||||||
;
|
|
||||||
|
|
||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
class FeedbackStatisticsFilterDto extends AbstractFilterDto
|
||||||
|
{
|
||||||
|
protected ?\DateTimeImmutable $dateFrom = null;
|
||||||
|
protected ?\DateTimeImmutable $dateTo = null;
|
||||||
|
|
||||||
|
public function getDateFrom(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\Feedback;
|
use App\Entity\Feedback;
|
||||||
|
use App\Entity\FeedbackSet;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Model\FeedbackFilterDto;
|
use App\Model\FeedbackFilterDto;
|
||||||
use App\Repository\Traits\QueryHelperTrait;
|
use App\Repository\Traits\QueryHelperTrait;
|
||||||
@@ -116,4 +117,103 @@ class FeedbackRepository extends ServiceEntityRepository
|
|||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns average ratings per question index grouped by FeedbackSet.
|
||||||
|
*
|
||||||
|
* @return array<int, array{
|
||||||
|
* feedbackSet: FeedbackSet,
|
||||||
|
* averages: array<int, float>,
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Common;
|
||||||
|
|
||||||
|
use App\Model\AbstractFilterDto;
|
||||||
|
use App\Model\FeedbackStatisticsFilterDto;
|
||||||
|
|
||||||
|
class FeedbackStatisticsFilterHandler extends AbstractFilterHandler
|
||||||
|
{
|
||||||
|
protected string $namespace = 'filter:feedback_statistics';
|
||||||
|
protected string $modelClass = FeedbackStatisticsFilterDto::class;
|
||||||
|
|
||||||
|
public function getFilterSettings(): FeedbackStatisticsFilterDto
|
||||||
|
{
|
||||||
|
$filterDto = new $this->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{% extends 'administrative/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Feedback-Statistik{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-start justify-between pb-4">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Feedback-Statistik
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow p-4 mb-8">
|
||||||
|
{{ form_start(form, { attr: { class: 'flex flex-wrap items-end gap-4' } }) }}
|
||||||
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
{{ form_row(form.dateFrom) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[150px]">
|
||||||
|
{{ form_row(form.dateTo) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{{ form_widget(form.apply, { attr: { class: 'btn btn--small' } }) }}
|
||||||
|
{{ form_widget(form.reset, { attr: { class: 'btn btn--small btn--secondary' } }) }}
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if filterDto.active %}
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
Filter aktiv:
|
||||||
|
{% if filterDto.dateFrom %}von {{ filterDto.dateFrom|date('d.m.Y') }}{% endif %}
|
||||||
|
{% if filterDto.dateTo %}bis {{ filterDto.dateTo|date('d.m.Y') }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if chartsData is empty %}
|
||||||
|
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||||
|
Keine Feedback-Daten im ausgewählten Zeitraum vorhanden.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="grid gap-8">
|
||||||
|
{% for chartData in chartsData %}
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div class="p-4 border-b bg-gray-50">
|
||||||
|
<h2 class="text-lg font-semibold">{{ chartData.feedbackSet.name }}</h2>
|
||||||
|
<p class="text-sm text-gray-600">{{ chartData.count }} Feedbacks</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col lg:flex-row">
|
||||||
|
<div class="lg:w-1/3 p-4 border-r">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 mb-3">Fragen</h3>
|
||||||
|
<ol class="text-sm space-y-2">
|
||||||
|
{% for question in chartData.questions %}
|
||||||
|
<li class="flex">
|
||||||
|
<span class="font-medium text-gray-400 w-6">{{ loop.index }}.</span>
|
||||||
|
<span>{{ question }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div class="lg:w-2/3 p-4">
|
||||||
|
<div class="h-64"
|
||||||
|
data-controller="chart"
|
||||||
|
data-chart-labels-value="{{ chartData.labels|json_encode }}"
|
||||||
|
data-chart-data-value="{{ chartData.data|json_encode }}"
|
||||||
|
data-chart-max-y-value="5">
|
||||||
|
<canvas data-chart-target="canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b">
|
||||||
|
{% for label in chartData.labels %}
|
||||||
|
<th class="py-1 text-center font-normal text-gray-500">{{ label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
{% for value in chartData.data %}
|
||||||
|
<td class="py-1 text-center font-semibold">{{ value|number_format(2, ',', '.') }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user