WIP: Implement frontend

This commit is contained in:
Björn Fromme
2023-09-18 13:10:20 +02:00
parent bfc5e5e242
commit ce97017ac4
70 changed files with 8751 additions and 337 deletions
+7
View File
@@ -4,6 +4,7 @@ namespace App\BusProNet;
use App\BusProNet\Model\BaseResponse;
use App\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\CacheInterface;
@@ -19,6 +20,7 @@ class ApiClient
private readonly SerializerInterface $serializer,
private readonly ResponseParser $responseParser,
private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
array $options
) {
$this->config = $this->resolveOptions($options);
@@ -57,6 +59,7 @@ class ApiClient
} catch (\Throwable $e) {
}
$this->logger->error('API error', ['error' => $e->getMessage()]);
throw new ApiClientException($e->getMessage());
}
@@ -96,6 +99,7 @@ class ApiClient
} catch (\Throwable $e) {
}
$this->logger->error('API error', ['error' => $e->getMessage()]);
throw new ApiClientException($e->getMessage());
}
@@ -128,6 +132,7 @@ class ApiClient
} catch (\Throwable $e) {
}
$this->logger->error('API error', ['error' => $e->getMessage()]);
throw new ApiClientException($e->getMessage());
}
@@ -164,6 +169,7 @@ class ApiClient
} catch (\Throwable $e) {
}
$this->logger->error('API error', ['error' => $e->getMessage()]);
throw new ApiClientException($e->getMessage());
}
@@ -197,6 +203,7 @@ class ApiClient
} catch (\Throwable $e) {
}
$this->logger->error('API error', ['error' => $e->getMessage()]);
throw new ApiClientException($e->getMessage());
});
}
+4 -2
View File
@@ -6,7 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\Entity\Teamer;
use App\Entity\User;
use App\Form\ProfileType;
use App\Form\TeamerProfileType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -36,7 +36,7 @@ class ProfileController extends AbstractController
$this->entityManager->persist($teamer);
}
$form = $this->createForm(ProfileType::class, $teamer);
$form = $this->createForm(TeamerProfileType::class, $teamer);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -47,6 +47,8 @@ class ProfileController extends AbstractController
} catch (ApiClientException $e) {
}
$this->addFlash('success', 'Deine Daten wurden aktualisiert');
return $this->redirectToRoute('app_teamer_profile');
}
+3 -3
View File
@@ -11,15 +11,15 @@ use Symfony\Component\Validator\Constraints as Assert;
class Address
{
#[ORM\Column(type: 'string', nullable: true)]
#[Assert\NotBlank(message: 'required_input')]
#[Assert\NotBlank(message: 'Bitte angeben')]
protected ?string $street = null;
#[ORM\Column(type: 'string', nullable: true)]
#[Assert\NotBlank(message: 'required_input')]
#[Assert\NotBlank(message: 'Bitte angeben')]
protected ?string $postCode = null;
#[ORM\Column(type: 'string', nullable: true)]
#[Assert\NotBlank(message: 'required_input')]
#[Assert\NotBlank(message: 'Bitte angeben')]
protected ?string $city = null;
#[ORM\Column(type: 'string', nullable: true)]
+6
View File
@@ -3,20 +3,26 @@
namespace App\Entity\Embeddable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Embeddable]
class BankAccount
{
#[ORM\Column(nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
#[Assert\Iban(message: 'Diese IBAN ist ungültig')]
private ?string $iban = null;
#[ORM\Column(nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $bic = null;
#[ORM\Column(nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $bank = null;
#[ORM\Column(nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $holder = null;
public function getIban(bool $obfuscated = false): ?string
+3 -1
View File
@@ -13,10 +13,12 @@ class Communication
protected ?string $phone = null;
#[ORM\Column(type: 'string', nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
protected ?string $mobile = null;
#[ORM\Column(type: 'string', nullable: true)]
#[Assert\Email(mode: 'strict')]
#[Assert\Email(mode: 'strict', message: 'Diese E-Mail-Adresse ist ungültig')]
#[Assert\NotBlank(message: 'Bitte angeben')]
protected ?string $email = null;
public function toPayload(): array
+15 -7
View File
@@ -32,15 +32,19 @@ class Teamer implements TimestampableEntityInterface
private string $uuid;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $firstName = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $lastName = null;
#[ORM\Column(length: 1)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $gender = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Assert\NotNull(message: 'Bitte angeben')]
private ?\DateTimeImmutable $dateOfBirth = null;
#[ORM\Column(length: 255, nullable: true)]
@@ -50,6 +54,7 @@ class Teamer implements TimestampableEntityInterface
private ?string $salutation = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $nationality = null;
#[ORM\Embedded(class: Address::class)]
@@ -65,13 +70,15 @@ class Teamer implements TimestampableEntityInterface
private ?BankAccount $bankAccount = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $taxId = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(message: 'Bitte angeben')]
private ?string $healthInsuranceCompany = null;
#[ORM\Column(length: 32)]
private ?string $status = null;
private ?string $status;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $remarks = null;
@@ -121,7 +128,7 @@ class Teamer implements TimestampableEntityInterface
{
// Transform gender value
$gender = strtoupper($this->getGender());
$gender = 'F' ? 'W' : $gender;
$gender = 'F' === $gender ? 'W' : $gender;
// Ensure date of birth is populated
if (null === $dob = $this->getDateOfBirth()) {
@@ -130,6 +137,7 @@ class Teamer implements TimestampableEntityInterface
return [
'geburtsdatum' => $dob->format('d.m.Y'),
'anrede' => $this->getSalutation(),
'geschlecht' => $gender,
'titel' => $this->getAcademicTitle(),
'vorname' => $this->getFirstName(),
@@ -178,7 +186,7 @@ class Teamer implements TimestampableEntityInterface
return $this->firstName;
}
public function setFirstName(string $firstName): static
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
@@ -190,7 +198,7 @@ class Teamer implements TimestampableEntityInterface
return $this->lastName;
}
public function setLastName(string $lastName): static
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
@@ -202,7 +210,7 @@ class Teamer implements TimestampableEntityInterface
return $this->gender;
}
public function setGender(string $gender): static
public function setGender(?string $gender): static
{
$this->gender = $gender;
@@ -214,7 +222,7 @@ class Teamer implements TimestampableEntityInterface
return $this->dateOfBirth;
}
public function setDateOfBirth(\DateTimeImmutable $dateOfBirth): static
public function setDateOfBirth(?\DateTimeImmutable $dateOfBirth): static
{
$this->dateOfBirth = $dateOfBirth;
@@ -250,7 +258,7 @@ class Teamer implements TimestampableEntityInterface
return $this->nationality;
}
public function setNationality(string $nationality): static
public function setNationality(?string $nationality): static
{
$this->nationality = $nationality;
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Form;
use App\Entity\Embeddable\Address;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('street', TextType::class, [
'label' => 'Straße, Hausnummer',
])
->add('postCode', TextType::class, [
'label' => 'PLZ',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
->add('country', BpnCountryType::class, [
'label' => 'Land',
'property' => 'country',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Address::class,
]);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Form;
use App\Entity\Embeddable\BankAccount;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BankAccountType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('iban', TextType::class, [
'label' => 'IBAN',
])
->add('bic', TextType::class, [
'label' => 'BIC',
])
->add('bank', TextType::class, [
'label' => 'Name der Bank',
])
->add('holder', TextType::class, [
'label' => 'Kontoinhaber',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BankAccount::class,
]);
}
}
@@ -28,7 +28,7 @@ class BpnCountryChoiceLoader implements ChoiceLoaderInterface
foreach ($countries as $country) {
$key = 'nationality' === $this->property ? $country->getNationality() : $country->getName();
$choices[$key] = $country->getId();
$choices[$key] = $country->getToken();
}
return new ArrayChoiceList($choices);
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Form;
use App\Entity\Embeddable\Communication;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CommunicationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => 'E-Mail',
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
])
->add('phone', TextType::class, [
'label' => 'Telefon (privat)',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Communication::class,
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AjaxSubmitExtension extends AbstractTypeExtension
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'ajax_submit' => false,
'ajax_action' => 'ajax-modal#submitForm',
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (true === $options['ajax_submit']) {
$view->vars['attr'] = array_merge($view->vars['attr'], ['data-action' => $options['ajax_action']]);
}
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NoValidateExtension extends AbstractTypeExtension
{
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (false === $options['clientside_validation'] && $form->isRoot()) {
$view->vars['attr'] = array_merge($view->vars['attr'], ['novalidate' => 'novalidate']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('clientside_validation', false);
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
}
@@ -6,11 +6,12 @@ use App\Entity\Teamer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProfileType extends AbstractType
class TeamerProfileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
@@ -23,6 +24,14 @@ class ProfileType extends AbstractType
'divers' => 'D',
],
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Frau' => 'Frau',
'Herr' => 'Herr',
'divers' => 'divers',
],
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
@@ -41,6 +50,30 @@ class ProfileType extends AbstractType
'label' => 'Nationalität',
'property' => 'nationality',
])
->add('taxId', TextType::class, [
'label' => 'Steuer-ID',
'required' => false,
])
->add('healthInsuranceCompany', TextType::class, [
'label' => 'Krankenversicherung',
'required' => false,
])
->add('remarks', TextareaType::class, [
'label' => 'Wünsche/Anmerkungen',
'required' => false,
'attr' => [
'rows' => 3,
],
])
->add('address', AddressType::class, [
'label' => 'false',
])
->add('communication', CommunicationType::class, [
'label' => false,
])
->add('bankAccount', BankAccountType::class, [
'label' => false,
])
;
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Menu;
use App\Entity\User;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
use Symfony\Contracts\Translation\TranslatorInterface;
class MenuBuilder
{
public function __construct(
protected FactoryInterface $factory,
protected Security $security,
protected RequestStack $requestStack,
protected TranslatorInterface $translator
) {
}
private function assertMobile(array $options): bool
{
return isset($options['mobile']) && true === (bool) $options['mobile'];
}
private function createRootElement(bool $isMobile): ItemInterface
{
return $this
->factory->createItem('root')
->setChildrenAttribute('class', 'menu'.($isMobile ? ' menu--mobile' : ''))
;
}
private function getDefaultRouteParameters(string $parameter = 'id', string $default = '0'): array
{
return [$parameter => $this->requestStack->getMainRequest()->get($parameter, $default)];
}
public function createMainMenu(array $options): ItemInterface
{
/** @var User $user */
$user = $this->security->getUser();
$userLabel = $user->getUserIdentifier();
$isMobile = $this->assertMobile($options);
$menu = $this->createRootElement($isMobile);
if ($this->security->isGranted('ROLE_TEAMER')) {
$menu
->addChild('Profil', ['route' => 'app_teamer_profile'])
->setLinkAttribute('title', 'Benutzer: '.$userLabel)
->setExtra('icon', 'user')
->setExtra('icon_only', false === $isMobile)
;
}
$token = $this->security->getToken();
if ($token instanceof SwitchUserToken) {
/** @var User $originalUser */
$originalUser = $token->getOriginalToken()->getUser();
$menu
->addChild('Logout', [
'route' => $originalUser->getDefaultRoute(),
'routeParameters' => ['_switch_user' => '_exit'],
])
->setLinkAttributes([
'title' => 'zurück',
'class' => 'icon-link',
])
->setExtra('icon', 'logout')
->setExtra('icon_only', false === $isMobile)
;
} else {
$menu
->addChild('Logout', ['route' => 'app_security_logout'])
->setLinkAttributes([
'title' => 'Logout',
'class' => 'icon-link',
])
->setExtra('icon', 'logout')
->setExtra('icon_only', false === $isMobile)
;
}
return $menu;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('file_size', [AppRuntime::class, 'formatBytes']),
new TwigFilter('file_icon', [AppRuntime::class, 'fileIconFilter'], ['is_safe' => ['html']]),
new TwigFilter('date_diff', [AppRuntime::class, 'dateDiffForHumans']),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('icon', [AppRuntime::class, 'renderIcon'], ['needs_environment' => true, 'is_safe' => ['html']]),
new TwigFunction('is_current_route', [AppRuntime::class, 'isCurrentRoute']),
new TwigFunction('qa_attribute', [AppRuntime::class, 'renderQaAttribute'], ['is_safe' => ['html']]),
];
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Twig;
use Carbon\Carbon;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;
class AppRuntime implements RuntimeExtensionInterface
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly string $environment
) {
}
public function fileIconFilter(Environment $environment, string $mimeType, ?string $classes = 'w-4 h-4'): string
{
$icon = match ($mimeType) {
'application/pdf' => 'pdf',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'excel',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'word',
default => 'download',
};
return $this->renderIcon($environment, $icon, $classes);
}
public function formatBytes(int $bytes, ?int $precision = 2): string
{
$size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
$factor = floor((strlen($bytes) - 1) / 3);
return sprintf("%.{$precision}f", $bytes / (1024 ** $factor)).@$size[$factor];
}
public function renderIcon(Environment $environment, string $icon, string $classes = 'w-6 h-6'): string
{
return $environment->render('_partials/_icon.html.twig', [
'icon' => $icon,
'class' => $classes,
]);
}
public function isCurrentRoute(string $route): bool
{
$requestedRoute = $this->requestStack->getMainRequest()->attributes->get('_route');
return $requestedRoute === $route;
}
public function renderQaAttribute(string $label, string $value = null): string
{
if ('test' !== $this->environment) {
return '';
}
if (null === $value) {
return sprintf(' data-qa-%s', strtolower($label));
}
return sprintf(' data-qa-%s="%s"', strtolower($label), $value);
}
public function dateDiffForHumans(\DateTimeInterface $dateTime, \DateTimeInterface $other = null): string
{
$oldLocale = Carbon::getLocale();
$locale = $this->requestStack->getMainRequest()->getLocale();
Carbon::setLocale($locale);
$result = Carbon::instance($dateTime)->diffForHumans($other);
Carbon::setLocale($oldLocale);
return $result;
}
}