feat: feedback statistics

addresses #869bgtz2d
This commit is contained in:
Björn Fromme
2025-12-23 14:54:27 +01:00
parent 2fb4fd750b
commit cdfd172a1e
16 changed files with 745 additions and 4 deletions
+73
View File
@@ -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()
}
}
}
+35
View File
@@ -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');
}
}
+19
View File
@@ -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",
+1
View File
@@ -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",
+200
View File
@@ -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,
]);
}
}
+3 -1
View File
@@ -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;
+43
View File
@@ -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,
])
;
}
}
-2
View File
@@ -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;
+16
View File
@@ -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',
+16
View File
@@ -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',
-1
View File
@@ -42,7 +42,6 @@ class DestinationDto
->setCountry($destination->getCountry())
->setPickups($pickupIds)
->setCostUnit($destination->getCostUnit());
;
return $instance;
}
+35
View File
@@ -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;
}
}
+100
View File
@@ -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<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 %}