feat: applications by destination statistics
addresses #869dv9fh6
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller\Administrative\Statistics;
|
||||||
|
|
||||||
|
use App\Form\StatisticsFilterType;
|
||||||
|
use App\Repository\ApplicationRepository;
|
||||||
|
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 ApplicationsByDestinationController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StatisticsFilterHandler $filterHandler,
|
||||||
|
private readonly ApplicationRepository $applicationRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/administrative/statistics/applications-by-destination', name: 'app_administrative_statistics_applications_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->applicationRepository->getApplicationsByDestination(
|
||||||
|
$filterDto->getDateFrom(),
|
||||||
|
$filterDto->getDateTo(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->render('administrative/statistics/applications_by_destination.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'filterDto' => $filterDto,
|
||||||
|
'statistics' => $statistics,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,12 @@ class AdminMenuBuilder extends AbstractMenuBuilder
|
|||||||
'title' => 'Bewertungen nach Destination',
|
'title' => 'Bewertungen nach Destination',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
$statisticsMenu->addChild('Bewerbungen pro Destination', [
|
||||||
|
'route' => 'app_administrative_statistics_applications_by_destination',
|
||||||
|
'linkAttributes' => [
|
||||||
|
'title' => 'Bewerbungen pro Destination',
|
||||||
|
],
|
||||||
|
]);
|
||||||
$settingsMenu = $menu->addChild('Einstellungen', [
|
$settingsMenu = $menu->addChild('Einstellungen', [
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Einstellungen',
|
'title' => 'Einstellungen',
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ class ManagerMenuBuilder extends AbstractMenuBuilder
|
|||||||
'title' => 'Bewertungen nach Destination',
|
'title' => 'Bewertungen nach Destination',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
$statisticsMenu->addChild('Bewerbungen pro Destination', [
|
||||||
|
'route' => 'app_administrative_statistics_applications_by_destination',
|
||||||
|
'linkAttributes' => [
|
||||||
|
'title' => 'Bewerbungen pro Destination',
|
||||||
|
],
|
||||||
|
]);
|
||||||
$settingsMenu = $menu->addChild('Einstellungen', [
|
$settingsMenu = $menu->addChild('Einstellungen', [
|
||||||
'linkAttributes' => [
|
'linkAttributes' => [
|
||||||
'title' => 'Einstellungen',
|
'title' => 'Einstellungen',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Entity\Assignment;
|
|||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Model\ApplicationFilterDto;
|
use App\Model\ApplicationFilterDto;
|
||||||
use App\Model\AssignmentFilterDto;
|
use App\Model\AssignmentFilterDto;
|
||||||
|
use App\Repository\Filter\SeasonPeriodFilter;
|
||||||
use App\Repository\Traits\QueryHelperTrait;
|
use App\Repository\Traits\QueryHelperTrait;
|
||||||
use Carbon\CarbonPeriodImmutable;
|
use Carbon\CarbonPeriodImmutable;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
@@ -250,4 +251,69 @@ class ApplicationRepository extends ServiceEntityRepository
|
|||||||
->getSingleScalarResult()
|
->getSingleScalarResult()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw tally grouped by normalized hotel code, split by application status.
|
||||||
|
*
|
||||||
|
* Mirrors DispositionRepository::getFeedbackStatisticsByNormalizedHotelCodeQuery() - the
|
||||||
|
* "Bewerbungen pro Destination" screen sits next to "Feedback pro Destination" and means the
|
||||||
|
* same thing by a destination and a season, so the normalization and the date filter are the
|
||||||
|
* shared ones.
|
||||||
|
*/
|
||||||
|
public function getApplicationsByDestinationQuery(
|
||||||
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
|
?\DateTimeImmutable $dateTo = null,
|
||||||
|
): Query {
|
||||||
|
$qb = $this->createQueryBuilder('application');
|
||||||
|
|
||||||
|
// Normalize hotel code: strip SER prefix to get base 3-char code
|
||||||
|
$normalizedHotelCode = "CASE WHEN destination.hotelCode LIKE 'SER%' THEN SUBSTRING(destination.hotelCode, 4, 3) ELSE SUBSTRING(destination.hotelCode, 1, 3) END";
|
||||||
|
|
||||||
|
$qb
|
||||||
|
->select(
|
||||||
|
$normalizedHotelCode.' AS hotelCode',
|
||||||
|
'SUM(CASE WHEN application.status = :statusNew THEN 1 ELSE 0 END) AS newCount',
|
||||||
|
'SUM(CASE WHEN application.status = :statusPending THEN 1 ELSE 0 END) AS pendingCount',
|
||||||
|
'SUM(CASE WHEN application.status = :statusRejected THEN 1 ELSE 0 END) AS rejectedCount',
|
||||||
|
'COUNT(application.id) AS totalCount'
|
||||||
|
)
|
||||||
|
->innerJoin('application.assignment', 'assignment')
|
||||||
|
->innerJoin('assignment.destination', 'destination')
|
||||||
|
->setParameter('statusNew', Application::STATUS_NEW)
|
||||||
|
->setParameter('statusPending', Application::STATUS_PENDING)
|
||||||
|
->setParameter('statusRejected', Application::STATUS_REJECTED)
|
||||||
|
->groupBy('hotelCode')
|
||||||
|
->orderBy('totalCount', 'DESC')
|
||||||
|
->addOrderBy('hotelCode', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
||||||
|
|
||||||
|
return $qb->getQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns application counts grouped by normalized hotel code (base 3-char code),
|
||||||
|
* split into the three application statuses plus a total.
|
||||||
|
*
|
||||||
|
* @return array<int, array{
|
||||||
|
* hotelCode: string,
|
||||||
|
* new: int,
|
||||||
|
* pending: int,
|
||||||
|
* rejected: int,
|
||||||
|
* total: int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function getApplicationsByDestination(
|
||||||
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
|
?\DateTimeImmutable $dateTo = null,
|
||||||
|
): array {
|
||||||
|
return array_map(static fn (array $row): array => [
|
||||||
|
'hotelCode' => $row['hotelCode'],
|
||||||
|
'new' => (int) $row['newCount'],
|
||||||
|
'pending' => (int) $row['pendingCount'],
|
||||||
|
'rejected' => (int) $row['rejectedCount'],
|
||||||
|
'total' => (int) $row['totalCount'],
|
||||||
|
], $this->getApplicationsByDestinationQuery($dateFrom, $dateTo)->getResult());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends 'administrative/layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Bewerbungen pro Destination{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-start justify-between pb-4">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Bewerbungen pro 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 statistics 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">
|
||||||
|
<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>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">Neu</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">In Prüfung</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">Abgelehnt</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">Gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for stat in statistics %}
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 font-medium">{{ stat.hotelCode }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">{{ stat.new }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">{{ stat.pending }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">{{ stat.rejected }}</td>
|
||||||
|
<td class="px-4 py-3 text-right"><span class="font-medium">{{ stat.total }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
{% set totalNew = 0 %}
|
||||||
|
{% set totalPending = 0 %}
|
||||||
|
{% set totalRejected = 0 %}
|
||||||
|
{% set totalAll = 0 %}
|
||||||
|
{% for stat in statistics %}
|
||||||
|
{% set totalNew = totalNew + stat.new %}
|
||||||
|
{% set totalPending = totalPending + stat.pending %}
|
||||||
|
{% set totalRejected = totalRejected + stat.rejected %}
|
||||||
|
{% set totalAll = totalAll + stat.total %}
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="bg-gray-100 font-semibold">
|
||||||
|
<td class="px-4 py-3">Gesamt</td>
|
||||||
|
<td class="px-4 py-3 text-right">{{ totalNew }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">{{ totalPending }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">{{ totalRejected }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">{{ totalAll }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -71,4 +71,59 @@ class ApplicationRepositoryTest extends KernelTestCase
|
|||||||
$period ?? new CarbonPeriodImmutable('2025-01-10', '2025-01-20')
|
$period ?? new CarbonPeriodImmutable('2025-01-10', '2025-01-20')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testApplicationsByDestinationGroupsByTheNormalizedHotelCode(): void
|
||||||
|
{
|
||||||
|
$dql = $this->createApplicationsByDestinationQuery()->getDQL();
|
||||||
|
|
||||||
|
// the SER prefix is stripped so a house counts once, however its code is spelled
|
||||||
|
$this->assertStringContainsString('SUBSTRING(destination.hotelCode, 4, 3)', $dql);
|
||||||
|
$this->assertStringContainsString('SUBSTRING(destination.hotelCode, 1, 3)', $dql);
|
||||||
|
$this->assertStringContainsString('GROUP BY hotelCode', $dql);
|
||||||
|
|
||||||
|
// a destination lives on the assignment, not the application
|
||||||
|
$this->assertStringContainsString('INNER JOIN application.assignment assignment', $dql);
|
||||||
|
$this->assertStringContainsString('INNER JOIN assignment.destination destination', $dql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplicationsByDestinationBindsTheStatusBuckets(): void
|
||||||
|
{
|
||||||
|
$query = $this->createApplicationsByDestinationQuery();
|
||||||
|
|
||||||
|
$this->assertSame(Application::STATUS_NEW, $query->getParameter('statusNew')->getValue());
|
||||||
|
$this->assertSame(Application::STATUS_PENDING, $query->getParameter('statusPending')->getValue());
|
||||||
|
$this->assertSame(Application::STATUS_REJECTED, $query->getParameter('statusRejected')->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApplicationsByDestinationAppliesTheSeasonBoundsOnlyWhenGiven(): void
|
||||||
|
{
|
||||||
|
$unfiltered = $this->createApplicationsByDestinationQuery();
|
||||||
|
$this->assertNull($unfiltered->getParameter('dateFrom'));
|
||||||
|
$this->assertNull($unfiltered->getParameter('dateTo'));
|
||||||
|
|
||||||
|
$filtered = $this->createApplicationsByDestinationQuery(
|
||||||
|
new \DateTimeImmutable('2025-05-01'),
|
||||||
|
new \DateTimeImmutable('2025-09-30'),
|
||||||
|
);
|
||||||
|
$dql = $filtered->getDQL();
|
||||||
|
|
||||||
|
// the season is the destination's date range, same as every other statistics screen
|
||||||
|
$this->assertStringContainsString('destination.dateFrom >= :dateFrom', $dql);
|
||||||
|
$this->assertStringContainsString('destination.dateTo <= :dateTo', $dql);
|
||||||
|
$this->assertSame('2025-05-01', $filtered->getParameter('dateFrom')->getValue()->format('Y-m-d'));
|
||||||
|
$this->assertSame('2025-09-30', $filtered->getParameter('dateTo')->getValue()->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createApplicationsByDestinationQuery(
|
||||||
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
|
?\DateTimeImmutable $dateTo = null,
|
||||||
|
): Query {
|
||||||
|
/** @var EntityManagerInterface $entityManager */
|
||||||
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
/** @var ApplicationRepository $repository */
|
||||||
|
$repository = $entityManager->getRepository(Application::class);
|
||||||
|
|
||||||
|
return $repository->getApplicationsByDestinationQuery($dateFrom, $dateTo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user