feat: average marks per destination
addresses #869bgtynb
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Administrative\Statistics;
|
||||||
|
|
||||||
|
use App\Form\StatisticsFilterType;
|
||||||
|
use App\Repository\FeedbackRepository;
|
||||||
|
use App\Service\Common\StatisticsFilterHandler;
|
||||||
|
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 FeedbackRatingsByDestinationController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StatisticsFilterHandler $filterHandler,
|
||||||
|
private readonly FeedbackRepository $feedbackRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/administrative/statistics/feedback-ratings-by-destination', name: 'app_administrative_statistics_feedback_ratings_by_destination')]
|
||||||
|
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$filterDto = $this->filterHandler->getFilterSettings();
|
||||||
|
|
||||||
|
$form = $this->createForm(StatisticsFilterType::class, $filterDto);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$filterDto = $this->filterHandler->handleRequest($form);
|
||||||
|
}
|
||||||
|
|
||||||
|
$statistics = $this->feedbackRepository->getAverageRatingsByDestinationAndFeedbackSet(
|
||||||
|
$filterDto->getDateFrom(),
|
||||||
|
$filterDto->getDateTo(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('administrative/statistics/feedback_ratings_by_destination.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'filterDto' => $filterDto,
|
||||||
|
'destinations' => $statistics['destinations'],
|
||||||
|
'feedbackSets' => $statistics['feedbackSets'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,21 +123,17 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Bewertungen nach Kategorie',
|
'title' => 'Bewertungen nach Kategorie',
|
||||||
],
|
],
|
||||||
'extras' => [
|
|
||||||
'routes' => [
|
|
||||||
['pattern' => '/^app_administrative_statistics_feedback_ratings/'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
$statisticsMenu->addChild('Feedback pro Destination', [
|
$statisticsMenu->addChild('Feedback pro Destination', [
|
||||||
'route' => 'app_administrative_statistics_feedback_by_destination',
|
'route' => 'app_administrative_statistics_feedback_by_destination',
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Feedback pro Destination',
|
'title' => 'Feedback pro Destination',
|
||||||
],
|
],
|
||||||
'extras' => [
|
]);
|
||||||
'routes' => [
|
$statisticsMenu->addChild('Bewertungen nach Destination', [
|
||||||
['pattern' => '/^app_administrative_statistics_feedback_by_destination/'],
|
'route' => 'app_administrative_statistics_feedback_ratings_by_destination',
|
||||||
],
|
'linkAttributes' => [
|
||||||
|
'title' => 'Bewertungen nach Destination',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
$settingsMenu = $menu->addChild('Einstellungen', [
|
$settingsMenu = $menu->addChild('Einstellungen', [
|
||||||
|
|||||||
@@ -104,21 +104,17 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
|||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Bewertungen nach Kategorie',
|
'title' => 'Bewertungen nach Kategorie',
|
||||||
],
|
],
|
||||||
'extras' => [
|
|
||||||
'routes' => [
|
|
||||||
['pattern' => '/^app_administrative_statistics_feedback_ratings/'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
$statisticsMenu->addChild('Feedback pro Destination', [
|
$statisticsMenu->addChild('Feedback pro Destination', [
|
||||||
'route' => 'app_administrative_statistics_feedback_by_destination',
|
'route' => 'app_administrative_statistics_feedback_by_destination',
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Feedback pro Destination',
|
'title' => 'Feedback pro Destination',
|
||||||
],
|
],
|
||||||
'extras' => [
|
]);
|
||||||
'routes' => [
|
$statisticsMenu->addChild('Bewertungen nach Destination', [
|
||||||
['pattern' => '/^app_administrative_statistics_feedback_by_destination/'],
|
'route' => 'app_administrative_statistics_feedback_ratings_by_destination',
|
||||||
],
|
'linkAttributes' => [
|
||||||
|
'title' => 'Bewertungen nach Destination',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
$settingsMenu = $menu->addChild('Einstellungen', [
|
$settingsMenu = $menu->addChild('Einstellungen', [
|
||||||
|
|||||||
@@ -118,6 +118,96 @@ class FeedbackRepository extends ServiceEntityRepository
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns overall average ratings grouped by normalized destination code and FeedbackSet.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* destinations: array<string, array<int, array{average: float, count: int}>>,
|
||||||
|
* feedbackSets: array<int, FeedbackSet>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getAverageRatingsByDestinationAndFeedbackSet(
|
||||||
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
|
?\DateTimeImmutable $dateTo = null,
|
||||||
|
): array {
|
||||||
|
$conn = $this->getEntityManager()->getConnection();
|
||||||
|
|
||||||
|
$sql = '
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN f.hotel_code LIKE \'SER%\' THEN SUBSTRING(f.hotel_code, 4, 3)
|
||||||
|
ELSE SUBSTRING(f.hotel_code, 1, 3)
|
||||||
|
END AS normalized_code,
|
||||||
|
f.feedback_set_id,
|
||||||
|
AVG(f.average_rating) AS avg_rating,
|
||||||
|
COUNT(*) AS feedback_count
|
||||||
|
FROM feedback f
|
||||||
|
WHERE f.status = :status
|
||||||
|
AND f.feedback_set_id IS NOT NULL
|
||||||
|
AND f.hotel_code IS NOT NULL
|
||||||
|
';
|
||||||
|
|
||||||
|
$params = ['status' => Feedback::STATUS_PUBLISHED];
|
||||||
|
|
||||||
|
if (null !== $dateFrom) {
|
||||||
|
$sql .= ' AND f.assignment_date_from >= :dateFrom';
|
||||||
|
$params['dateFrom'] = $dateFrom->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $dateTo) {
|
||||||
|
$sql .= ' AND f.assignment_date_to <= :dateTo';
|
||||||
|
$params['dateTo'] = $dateTo->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= '
|
||||||
|
GROUP BY normalized_code, f.feedback_set_id
|
||||||
|
ORDER BY normalized_code, f.feedback_set_id
|
||||||
|
';
|
||||||
|
|
||||||
|
$results = $conn->executeQuery($sql, $params)->fetchAllAssociative();
|
||||||
|
|
||||||
|
// Collect all feedback set IDs and fetch entities
|
||||||
|
$feedbackSetIds = array_unique(array_column($results, 'feedback_set_id'));
|
||||||
|
$feedbackSetRepository = $this->getEntityManager()->getRepository(FeedbackSet::class);
|
||||||
|
$feedbackSets = [];
|
||||||
|
|
||||||
|
foreach ($feedbackSetIds as $id) {
|
||||||
|
$feedbackSet = $feedbackSetRepository->find($id);
|
||||||
|
if (null !== $feedbackSet) {
|
||||||
|
$feedbackSets[$id] = $feedbackSet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pivot structure: destinations[code][feedbackSetId] = {average, count}
|
||||||
|
$destinations = [];
|
||||||
|
|
||||||
|
foreach ($results as $row) {
|
||||||
|
$code = $row['normalized_code'];
|
||||||
|
$feedbackSetId = (int) $row['feedback_set_id'];
|
||||||
|
|
||||||
|
if (false === isset($feedbackSets[$feedbackSetId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === isset($destinations[$code])) {
|
||||||
|
$destinations[$code] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert from percentage (stored as integer * 100) to 1-5 scale
|
||||||
|
$destinations[$code][$feedbackSetId] = [
|
||||||
|
'average' => round((float) $row['avg_rating'] / 100, 2),
|
||||||
|
'count' => (int) $row['feedback_count'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($destinations);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'destinations' => $destinations,
|
||||||
|
'feedbackSets' => $feedbackSets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns average ratings per question index grouped by FeedbackSet.
|
* Returns average ratings per question index grouped by FeedbackSet.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends 'administrative/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Bewertungen nach Destination{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-start justify-between pb-4">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Bewertungen nach Destination
|
||||||
|
</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 destinations is empty %}
|
||||||
|
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||||
|
Keine Daten im ausgewählten Zeitraum vorhanden.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 border-b">
|
||||||
|
<th class="px-4 py-3 text-left font-semibold">Destination</th>
|
||||||
|
{% for feedbackSet in feedbackSets %}
|
||||||
|
<th class="px-4 py-3 text-center font-semibold">{{ feedbackSet.name }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for code, data in destinations %}
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 font-medium">{{ code }}</td>
|
||||||
|
{% for feedbackSetId, feedbackSet in feedbackSets %}
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
{% if data[feedbackSetId] is defined %}
|
||||||
|
{% set avg = data[feedbackSetId].average %}
|
||||||
|
{% set count = data[feedbackSetId].count %}
|
||||||
|
<span class="font-semibold {% if avg >= 4 %}text-green-600{% elseif avg >= 3 %}text-yellow-600{% else %}text-red-600{% endif %}">
|
||||||
|
{{ avg|number_format(2, ',', '.') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400 text-xs">({{ count }})</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-300">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user