WIP: Implement feedback functionality

This commit is contained in:
Björn Fromme
2023-11-05 18:19:58 +01:00
parent 47f2aa17be
commit 908b904b67
15 changed files with 346 additions and 75 deletions
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Entity\Feedback;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20231105160706 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback ADD status VARCHAR(32) NOT NULL');
}
public function postUp(Schema $schema): void
{
$this->connection->executeQuery('UPDATE feedback SET status=?', [Feedback::STATUS_NEW]);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE feedback DROP status');
}
}
@@ -3,19 +3,45 @@
namespace App\Controller\Admin\Teamer;
use App\Entity\Teamer;
use App\Repository\DispositionRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class RecentAssignmentsController extends AbstractController
{
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly PaginatorInterface $paginator
) {
}
#[Route('/admin/teamer/recent-assignments/{uuid}', name: 'app_admin_teamer_recent_assignments')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Teamer $teamer): Response
public function index(Teamer $teamer, Request $request): Response
{
$query = $this
->dispositionRepository
->getRecentQuery($teamer)
;
$pagination = $this->paginator->paginate(
$query,
$request->query->getInt('page', 1),
10,
[
'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc',
]
);
return $this->render('admin/teamer/recent_assignments.html.twig', [
'teamer' => $teamer,
'pagination' => $pagination,
]);
}
}
@@ -30,13 +30,13 @@ class ProvideController extends AbstractController
{
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$feebackSet = $assignment
$feedbackSet = $assignment
->getJobProfile()
->getFeedbackSet()
;
$feedback = new Feedback();
$form = $this->createForm(FeedbackType::class, $feedback, ['feedback_set' => $feebackSet]);
$form = $this->createForm(FeedbackType::class, $feedback, ['feedback_set' => $feedbackSet]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
+33
View File
@@ -15,6 +15,9 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
use BlameableEntity;
use TimestampableEntity;
public const STATUS_NEW = 'new';
public const STATUS_PUBLISHED = 'published';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -23,6 +26,9 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(length: 32)]
private ?string $status = self::STATUS_NEW;
#[ORM\ManyToOne(inversedBy: 'feedback')]
private ?Teamer $teamer = null;
@@ -56,6 +62,18 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
return $this->uuid;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getTeamer(): ?Teamer
{
return $this->teamer;
@@ -104,6 +122,21 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
return $this;
}
public function getAverageRating(): ?int
{
if (0 === count($this->ratings)) {
return null;
}
$sum = 0;
foreach ($this->ratings as $rating) {
$sum += $rating['mark'];
}
return round($sum/count($this->ratings));
}
public function getComment(): ?string
{
return $this->comment;
+19 -13
View File
@@ -2,7 +2,7 @@
namespace App\Form;
use App\Form\DataTransformer\EntityToIdTransformer;
use App\Form\DataTransformer\EntityToIdentifierTransformer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -21,15 +21,19 @@ class AutocompleteEntityType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$dataTransformer = new EntityToIdTransformer($this->entityManager, $options['class']);
$dataTransformer = new EntityToIdentifierTransformer(
$this->entityManager,
$options['class'],
$options['identifier']
);
$builder->addModelTransformer($dataTransformer);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(['class']);
$resolver->setDefaults([
$resolver
->setRequired(['class'])
->setDefaults([
'endpoint_route' => null,
'choices' => [],
'compound' => false,
@@ -38,10 +42,12 @@ class AutocompleteEntityType extends AbstractType
'placeholder' => null,
'endpoint_parameters' => [],
'controller_action' => null,
]);
$resolver->setAllowedTypes('choices', 'array');
$resolver->setAllowedTypes('label_function', ['null', 'callable']);
'identifier' => 'id',
])
->setAllowedTypes('choices', 'array')
->setAllowedTypes('label_function', ['null', 'callable'])
->setAllowedValues('identifier', ['id', 'uuid'])
;
}
public function buildView(FormView $view, FormInterface $form, array $options): void
@@ -57,17 +63,17 @@ class AutocompleteEntityType extends AbstractType
$view->vars['initial_label'] = '';
$view->vars['action'] = $options['controller_action'];
$entity = $form->getData();
if (null === $entity = $form->getData()) {
return;
}
if (null !== $entity) {
if (null !== $options['label_function']) {
$labelFunction = $options['label_function'];
$view->vars['initial_label'] = $labelFunction($entity);
} else {
$getter = 'get'.ucfirst($options['label_property']);
if (method_exists($entity, $getter)) {
$view->vars['initial_label'] = $entity->$getter();
}
$view->vars['initial_label'] = call_user_func([$entity, $getter]);
}
}
}
@@ -1,38 +0,0 @@
<?php
namespace App\Form\DataTransformer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class EntityToIdTransformer implements DataTransformerInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly string $class)
{
}
public function transform($value)
{
if (null === $value) {
return null;
}
return $value->getId();
}
public function reverseTransform($value)
{
if (empty($value)) {
return null;
}
$entity = $this->entityManager->getRepository($this->class)->find($value);
if (null === $entity) {
throw new TransformationFailedException(sprintf('No %s with id %d found', $this->class, $value));
}
return $entity;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Form\DataTransformer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class EntityToIdentifierTransformer implements DataTransformerInterface
{
private string $identifier;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $class,
string $identifier = 'id'
) {
if (false === in_array($identifier, ['id', 'uuid'])) {
throw new \InvalidArgumentException(sprintf('Identifier must be one of "id" or "uuid" got "%s"', $identifier));
}
$this->identifier = $identifier;
}
public function transform($value)
{
if (null === $value) {
return null;
}
$getter = 'get'.ucfirst($this->identifier);
return call_user_func([$value, $getter]);
}
public function reverseTransform($value)
{
if (empty($value)) {
return null;
}
$entity = $this
->entityManager
->getRepository($this->class)
->findOneBy([$this->identifier => $value])
;
if (null === $entity) {
throw new TransformationFailedException(sprintf('No %s with %s %s found', $this->class, $this->identifier, $value));
}
return $entity;
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Form\DataTransformer;
use App\Entity\FeedbackSet;
use Symfony\Component\Form\DataTransformerInterface;
class FeedbackToRatingsTransformer implements DataTransformerInterface
{
public function __construct(private readonly FeedbackSet $feedbackSet)
{
}
public function transform(mixed $value)
{
// Not in use
return $value;
}
public function reverseTransform(mixed $value)
{
$feedbackRatings = [];
foreach ($this->feedbackSet->getRatings() as $index => $rating) {
$feedbackRatings[] = [
'rating' => $rating,
'mark' => $value['rating_'.$index],
];
}
return $feedbackRatings;
}
}
+8
View File
@@ -4,6 +4,7 @@ namespace App\Form;
use App\Entity\Feedback;
use App\Entity\FeedbackSet;
use App\Form\DataTransformer\FeedbackToRatingsTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -26,6 +27,13 @@ class FeedbackType extends AbstractType
],
])
;
// This is most probably more suitable for DatamapperInterface
// but it works very well for the time being
$builder
->get('ratings')
->addModelTransformer(new FeedbackToRatingsTransformer($options['feedback_set']))
;
}
public function configureOptions(OptionsResolver $resolver): void
+8 -2
View File
@@ -2,7 +2,7 @@
namespace App\Form;
use App\Form\DataTransformer\EntityToIdTransformer;
use App\Form\DataTransformer\EntityToIdentifierTransformer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
@@ -19,7 +19,11 @@ class HiddenEntityType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$transformer = new EntityToIdTransformer($this->entityManager, $options['class']);
$transformer = new EntityToIdentifierTransformer(
$this->entityManager,
$options['class'],
$options['identifier']
);
$builder->addModelTransformer($transformer);
}
@@ -34,7 +38,9 @@ class HiddenEntityType extends AbstractType
->setRequired(['class'])
->setDefaults([
'invalid_message' => 'The entity does not exist.',
'identifier' => 'id',
])
->setAllowedValues('identifier', ['id', 'uuid'])
;
}
+5 -2
View File
@@ -57,10 +57,11 @@ class DispositionRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('disposition');
return $qb
->select('disposition', 'assignment', 'job_profile', 'destination')
->select('disposition', 'assignment', 'job_profile', 'destination', 'feedback')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.jobProfile', 'job_profile')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.feedback', 'feedback')
->where($qb->expr()->andX(
$qb->expr()->eq('disposition.teamer', ':teamer'),
$qb->expr()->orX(
@@ -99,12 +100,14 @@ class DispositionRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('disposition');
return $qb
->select('disposition', 'assignment', 'destination')
->select('disposition', 'assignment', 'destination', 'feedback')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.feedback', 'feedback')
->where($qb->expr()->eq('disposition.teamer', ':teamer'))
->setParameter('teamer', $teamer)
->orderBy('destination.dateFrom', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult()
;
+1 -1
View File
@@ -28,7 +28,7 @@ class DispositionVoter extends Voter
return false;
}
return in_array($attribute, [static::VIEW, static::EDIT, static::DELETE, static::CONTRACT, static::INVOICE]);
return in_array($attribute, [static::VIEW, static::EDIT, static::DELETE, static::CONTRACT, static::INVOICE, static::FEEDBACK]);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Security\Voter;
use App\Entity\Feedback;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class FeedbackVoter extends Voter
{
public const VIEW = 'VIEW';
public const PUBLISH = 'PUBLISH';
public const DELETE = 'DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return $subject instanceof Feedback && in_array($attribute, [static::VIEW, static::PUBLISH, static::DELETE]);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
/** @var Feedback $feedback */
$feedback = $subject;
if (in_array('ROLE_ADMIN', $token->getRoleNames())) {
return true;
}
if (in_array('ROLE_HOUSE_MANAGER', $token->getRoleNames())) {
return static::VIEW === $attribute;
}
if (in_array('ROLE_TEAMER', $token->getRoleNames())) {
/** @var User $user */
$user = $token->getUser();
$teamer = $user->getTeamer();
return $feedback->getTeamer() === $teamer;
}
return false;
}
}
+2 -2
View File
@@ -70,12 +70,12 @@
{% endif %}
</td>
<td>
<a href="#">
<a href="{{ path('app_admin_teamer_profile', { 'uuid': teamer.uuid }) }}">
<span class="whitespace-nowrap">{{ teamer.lastName }}</span>
</a>
</td>
<td>
<a href="#" class="whitespace-nowrap">
<a href="{{ path('app_admin_teamer_profile', { 'uuid': teamer.uuid }) }}" class="whitespace-nowrap">
{{ teamer.firstName }}
</a>
</td>
@@ -11,6 +11,65 @@
<h1 class="text-2xl font-bold pb-8">
Vergangene Einsätze &amp; Feeback {{ teamer }}
</h1>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th>
Einsatz
</th>
<th>
Gesamtnote
</th>
<th>
Kommentar
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for disposition in pagination %}
<tr>
<td>
{{ disposition.assignment.destination.dateFrom|date('d.m.Y') }}
<br>
{{ disposition.assignment.destination.product }}
<br>
{{ disposition.assignment.destination.hotel }}
</td>
<td>
{% if disposition.feedback %}
{{ disposition.feedback.averageRating }}
{% else %}
-
{% endif %}
</td>
<td>
{% if disposition.feedback %}
<div class="truncate">
{{ disposition.feedback.comment|default('-') }}
</div>
{% else %}
-
{% endif %}
</td>
<td>
<button>
{{ icon('feedback') }}
</button>
</td>
</tr>
{% else %}
<td colspan="7">
Keine Daten...
</td>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
</div>
</div>
{% endblock %}