WIP: Implement role house manager and feedback providing

This commit is contained in:
Björn Fromme
2023-11-04 18:24:54 +01:00
parent 538d39f63e
commit d5da0d10d5
23 changed files with 576 additions and 37 deletions
+3
View File
@@ -1,4 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="icon-feedback" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</symbol>
<symbol id="icon-login" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</symbol>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+8
View File
@@ -85,6 +85,14 @@ services:
method: createMainMenu
alias: teamer_main
App\Menu\HouseManagerMenuBuilder:
arguments:
$factory: '@knp_menu.factory'
tags:
- name: knp_menu.menu_builder
method: createMainMenu
alias: house_manager_main
App\Service\Upload\UploadHandler:
arguments:
$orphanageManager: '@oneup_uploader.orphanage_manager'
+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 Version20231104170014 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 disposition DROP FOREIGN KEY FK_4C58BF60D249A887');
$this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF60D249A887 FOREIGN KEY (feedback_id) REFERENCES feedback (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE feedback ADD comment LONGTEXT DEFAULT NULL, ADD comment_public TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE disposition DROP FOREIGN KEY FK_4C58BF60D249A887');
$this->addSql('ALTER TABLE disposition ADD CONSTRAINT FK_4C58BF60D249A887 FOREIGN KEY (feedback_id) REFERENCES feedback (id)');
$this->addSql('ALTER TABLE feedback DROP comment, DROP comment_public');
}
}
@@ -37,14 +37,16 @@ class HotelDataProvider
return $this->getAll()[$busProId] ?? null;
}
public function findByCode(string $code): ?Hotel
public function findByCode(string $code): array
{
$hotels = [];
foreach ($this->getAll() as $hotel) {
if ($code === $hotel->getCode()) {
return $hotel;
if (str_starts_with($hotel->getCode(), $code)) {
$hotels[] = $hotel;
}
}
return null;
return $hotels;
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Controller\HouseManager\Feedback;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\Model\Hotel;
use App\Entity\User;
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 IndexController extends AbstractController
{
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly HotelDataProvider $hotelDataProvider,
private readonly PaginatorInterface $paginator
) {
}
#[Route('/house-manager/pending-feedback', name: 'app_house_manager_feedback_index')]
#[IsGranted('ROLE_HOUSE_MANAGER')]
public function index(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$hotels = $this
->hotelDataProvider
->findByCode($user->getHotelCode())
;
$hotelBusProIds = array_map(function (Hotel $hotel) {
return $hotel->getBusProId();
}, $hotels);
$query = $this
->dispositionRepository
->getPendingFeedbackQuery($hotelBusProIds)
;
$pagination = $this->paginator->paginate(
$query,
$request->query->getInt('page', 1),
10,
[
'defaultSortFieldName' => 'assignment.dateFrom',
'defaultSortDirection' => 'asc',
]
);
return $this->render('house_manager/feedback/index.html.twig', [
'pagination' => $pagination,
]);
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Controller\HouseManager\Feedback;
use App\Entity\Disposition;
use App\Entity\Feedback;
use App\Event\FeedbackProvidedEvent;
use App\Form\FeedbackType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
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;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class ProvideController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly LoggerInterface $logger
) {
}
#[Route('/house-manager/feedback/provide/{uuid}', name: 'app_house_manager_feedback_provide')]
#[IsGranted('FEEDBACK', subject: 'disposition')]
public function index(Disposition $disposition, Request $request): Response
{
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$feebackSet = $assignment
->getJobProfile()
->getFeedbackSet()
;
$feedback = new Feedback();
$form = $this->createForm(FeedbackType::class, $feedback, ['feedback_set' => $feebackSet]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$feedback
->setAssignmentDestination($destination)
->setAssignmentDate($assignment->getEffectivePeriod()->start->toDateTimeImmutable())
;
$teamer = $disposition->getTeamer();
$teamer->addFeedback($feedback);
$disposition->setFeedback($feedback);
$this->entityManager->persist($feedback);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new FeedbackProvidedEvent($feedback), FeedbackProvidedEvent::NAME);
$this->logger->info('Feedback provided', [
'feedback_id' => $feedback->getId(),
'teamer' => (string) $teamer,
'destination' => (string) $destination,
]);
$this->addFlash('success', 'Das Feedback wurde entgegengenommen');
return $this->redirectToRoute('app_house_manager_feedback_index');
}
return $this->render('house_manager/feedback/provide.html.twig', [
'form' => $form,
'disposition' => $disposition,
]);
}
}
@@ -11,6 +11,6 @@ class IndexController extends AbstractController
#[Route('/house-manager', name: 'app_house_manager_index')]
public function index(): Response
{
return $this->render('');
return $this->render('house_manager/index.html.twig');
}
}
+1
View File
@@ -49,6 +49,7 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
private Collection $documents;
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(onDelete: 'SET NULL')]
private ?Feedback $feedback = null;
public function __construct(Application $application)
+30
View File
@@ -35,6 +35,12 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
#[ORM\Column]
private array $ratings = [];
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $comment = null;
#[ORM\Column]
private ?bool $commentPublic = false;
public function __construct()
{
$this->uuid = Uuid::v4();
@@ -97,4 +103,28 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): static
{
$this->comment = $comment;
return $this;
}
public function isCommentPublic(): ?bool
{
return $this->commentPublic;
}
public function setCommentPublic(bool $commentPublic): static
{
$this->commentPublic = $commentPublic;
return $this;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Event;
use App\Entity\Feedback;
use Symfony\Contracts\EventDispatcher\Event;
class FeedbackProvidedEvent extends Event
{
public const NAME = 'feedback.provided';
public function __construct(private readonly Feedback $feedback)
{
}
public function getFeedback(): Feedback
{
return $this->feedback;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Form;
use App\Entity\FeedbackSet;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class FeedbackRatingsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$ratings = $options['feedback_set']->getRatings();
foreach ($ratings as $index => $rating) {
$builder->add('rating_'.$index, ChoiceType::class, [
'label' => $rating,
'choices' => array_combine(range(1, 6), range(1,6)),
]);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setRequired(['feedback_set'])
->setAllowedTypes('feedback_set', FeedbackSet::class)
;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Form;
use App\Entity\Feedback;
use App\Entity\FeedbackSet;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class FeedbackType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('ratings', FeedbackRatingsType::class, [
'label' => false,
'feedback_set' => $options['feedback_set'],
])
->add('comment', TextareaType::class, [
'label' => 'Kommentar',
'required' => false,
'attr' => [
'rows' => 5,
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefaults([
'data_class' => Feedback::class,
])
->setRequired(['feedback_set'])
->setAllowedTypes('feedback_set', FeedbackSet::class)
;
}
}
+11
View File
@@ -99,6 +99,17 @@ abstract class AbstractMenuBuilder
}
}
protected function addHouseManagerItem(ItemInterface $menu): void
{
if ($this->security->isGranted('ROLE_HOUSE_MANAGER')) {
$menu
->addChild('zum Hausmanagerbereich', ['route' => 'app_house_manager_index'])
->setChildrenAttribute('title', 'zum Hausmanagerbereich')
->setExtra('icon', 'user')
;
}
}
protected function addLogoutItem(ItemInterface $menu): void
{
$token = $this->security->getToken();
+5 -1
View File
@@ -39,7 +39,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
],
[
'route' => 'app_admin_teamer_index',
'title' => 'Teamer:innenübersicht',
'title' => 'Teamübersicht',
'icon' => 'users',
'hideChildren' => true,
'children' => $this->getTeamerMenuItems(),
@@ -165,6 +165,10 @@ class AdminMenuBuilder extends AbstractMenuBuilder
$this->addDivider($menu);
$this->addHouseManagerItem($menu);
$this->addDivider($menu);
$this->addLogoutItem($menu);
return $menu;
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Menu;
use Knp\Menu\ItemInterface;
class HouseManagerMenuBuilder extends AbstractMenuBuilder
{
public function createMainMenu(array $options): ItemInterface
{
$menuItems = [
[
'route' => 'app_house_manager_index',
'title' => 'Dashboard',
'icon' => 'chart',
],
[
'route' => 'app_house_manager_feedback_index',
'title' => 'Feedback',
'icon' => 'feedback',
'hideChildren' => true,
'children' => [
[
'route' => 'app_house_manager_feedback_provide',
'title' => 'Feedback abgeben',
'routeParameters' => $this->getDefaultRouteParameters('uuid'),
]
],
],
];
$menu = $this->createMenu($menuItems);
$this->addDivider($menu);
$this->addAdminItem($menu);
$this->addDivider($menu);
$this->addLogoutItem($menu);
return $menu;
}
}
+25
View File
@@ -109,4 +109,29 @@ class DispositionRepository extends ServiceEntityRepository
->getResult()
;
}
public function getPendingFeedbackQuery(array $hotelBusProIds = null): Query
{
$qb = $this->createQueryBuilder('disposition');
$qb
->select('disposition', 'assignment', 'destination')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->where($qb->expr()->andX(
$qb->expr()->isNull('disposition.feedback'),
$qb->expr()->lt('destination.dateTo', ':dateTo')
))
->setParameter('dateTo', new \DateTimeImmutable())
;
if (null !== $hotelBusProIds) {
$qb
->andWhere($qb->expr()->in('destination.hotelBusProId', ':hotelBusProIds'))
->setParameter('hotelBusProIds', $hotelBusProIds)
;
}
return $qb->getQuery();
}
}
-25
View File
@@ -38,29 +38,4 @@ class FeedbackRepository extends ServiceEntityRepository
$this->getEntityManager()->flush();
}
}
// /**
// * @return Feedback[] Returns an array of Feedback objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('f')
// ->andWhere('f.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('f.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Feedback
// {
// return $this->createQueryBuilder('f')
// ->andWhere('f.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
+31 -4
View File
@@ -2,6 +2,8 @@
namespace App\Security\Voter;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\Model\Hotel;
use App\Entity\Disposition;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
@@ -14,6 +16,11 @@ class DispositionVoter extends Voter
public const DELETE = 'DELETE';
public const CONTRACT = 'CONTRACT';
public const INVOICE = 'INVOICE';
public const FEEDBACK = 'FEEDBACK';
public function __construct(private readonly HotelDataProvider $hotelDataProvider)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
@@ -26,18 +33,38 @@ class DispositionVoter extends Voter
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
/** @var Disposition $disposition */
$disposition = $subject;
/** @var User $user */
$user = $token->getUser();
// Administrative users have full access to all dispositions
if (in_array('ROLE_ADMINISTRATIVE', $token->getRoleNames())) {
return true;
}
// House managers may provide feedback for dispositions that are past
// and are associated with their hotel
if (in_array('ROLE_HOUSE_MANAGER', $token->getRoleNames()) && static::FEEDBACK === $attribute) {
$hotels = $this
->hotelDataProvider
->findByCode($user->getHotelCode())
;
$hotelBusProIds = array_map(function (Hotel $hotel) {
return $hotel->getBusProId();
}, $hotels);
$destination = $disposition
->getAssignment()
->getDestination()
;
return in_array($destination->getHotelBusProId(), $hotelBusProIds)
&& $destination->getDateTo() < new \DateTimeImmutable();
}
// Teamers may only view or edit their own dispositions
if (in_array('ROLE_TEAMER', $token->getRoleNames())) {
/** @var User $user */
$user = $token->getUser();
$teamer = $user->getTeamer();
/** @var Disposition $disposition */
$disposition = $subject;
switch ($attribute) {
case static::VIEW:
+2 -2
View File
@@ -1,11 +1,11 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Teamer:innenübersicht{% endblock %}
{% block title %}Teamerübersicht{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Teamer:innenübersicht
Teamerübersicht
</h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<button type="button"
@@ -0,0 +1,108 @@
{% extends 'house_manager/layout.html.twig' %}
{% block title %}Ausstehendes Feedback{% endblock %}
{% block content %}
<div class="flex items-start justify-between pb-4">
<h1 class="text-2xl font-bold">
Ausstehendes Feedback
</h1>
</div>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner">
<table class="data-table">
<thead>
<tr>
<th></th>
<th>
{{ knp_pagination_sortable(pagination, 'Name', 'teamer.lastName') }}
</th>
<th>
Vorname
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Einsatz&shy;zeitraum', 'destination.dateFrom') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Destination', 'destination.product') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Jobprofil', 'job_profile.name') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for disposition in pagination %}
{% set teamer = disposition.teamer %}
{% set assignment = disposition.assignment %}
<tr>
<td>
{% if teamer.photo %}
<img src="{{ asset(teamer.photo.filename | imagine_filter('profile')) }}"
class="w-12 h-auto rounded-full"
alt="{{ teamer.firstName }}">
{% else %}
<div class="flex flex-col items-center justify-center w-12 h-12 border-2 border-gray-200 rounded-full">
{{ icon('user', 'w-10 h-10 text-gray-200') }}
</div>
{% endif %}
</td>
<td>
<a href="{{ path('app_house_manager_feedback_provide', { 'uuid': disposition.uuid }) }}"
title="Feedback abgeben">
<span class="whitespace-nowrap">{{ teamer.lastName }}</span>
</a>
</td>
<td>
<a href="{{ path('app_house_manager_feedback_provide', { 'uuid': disposition.uuid }) }}"
title="Feedback abgeben">
<span class="whitespace-nowrap">{{ teamer.firstName }}</span>
</a>
</td>
<td>
<a href="{{ path('app_house_manager_feedback_provide', { 'uuid': disposition.uuid }) }}"
title="Feedback abgeben">
{{ assignment.effectivePeriod.start|date('d.m.Y') }} -
<br>
{{ assignment.effectivePeriod.end|date('d.m.Y') }}
</a>
</td>
<td>
<a href="{{ path('app_house_manager_feedback_provide', { 'uuid': disposition.uuid }) }}"
title="Feedback abgeben"
class="flex items-start space-x-2">
{{ icon('flag-' ~ assignment.destination.country, 'w-5 h-5 shrink-0') }}
<div class="flex-1">
{{ assignment.destination.product }}
<br>
{{ assignment.destination.hotel }}
</div>
</a>
</td>
<td>
<a href="{{ path('app_house_manager_feedback_provide', { 'uuid': disposition.uuid }) }}"
title="Feedback abgeben">
{{ assignment.jobProfile.name }}
</a>
</td>
<td>
<div class="flex items-center space-x-1 justify-end">
<a href="{{ path('app_house_manager_feedback_provide', { 'uuid': disposition.uuid }) }}"
title="Feedback abgeben">
{{ icon('feedback') }}
</a>
</div>
</td>
</tr>
{% else %}
<td colspan="7">
Keine Daten...
</td>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% extends 'house_manager/layout.html.twig' %}
{% block title %}Feedback abgeben{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold pb-4">
Dein Feedback zu {{ disposition.teamer }}
</h1>
<div class="pb-8 text-lg">
{{ disposition.assignment.jobProfile.name }}, {{ disposition.assignment.destination }}
</div>
{{ form_start(form) }}
<div class="flex flex-col divide-y divide-gray-200">
{% for child in form.ratings.children %}
<div class="grid grid-cols-4 gap-x-4 items-start py-4">
<h2 class="col-span-3 text-lg font-bold">
{{ child.vars.label }}
</h2>
{{ form_widget(child) }}
</div>
{% endfor %}
<div class="py-4">
{{ form_row(form.comment) }}
</div>
</div>
<button type="submit" class="btn">
Speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+4
View File
@@ -0,0 +1,4 @@
{% extends 'house_manager/layout.html.twig' %}
{% block content %}
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends 'layout.html.twig' %}
{% block main_menu %}
{{ knp_menu_render(knp_menu_get('house_manager_main')) }}
{% endblock %}
{% block mobile_menu %}
{{ knp_menu_render(knp_menu_get('house_manager_main')) }}
{% endblock %}