add admin panel

Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Reviewed-on: #20
This commit is contained in:
2025-02-09 14:59:31 +00:00
parent e3a67adf63
commit 2ccba65185
31 changed files with 1773 additions and 17 deletions

View File

@ -0,0 +1,65 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Payment;
use App\Enum\TicketData;
use Doctrine\Common\Collections\Collection;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TelephoneField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use App\Form\TicketType;
class CustomerCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Customer::class;
}
public function configureFields(string $pageName): iterable
{
yield TextField::new('firstname', 'Vorname');
yield TextField::new('lastname', 'Nachname');
yield EmailField::new('email', 'E-Mail');
yield TelephoneField::new('phone', 'Telefon');
yield AssociationField::new('payment', 'Total')
->setCrudController(PaymentCrudController::class)
->formatValue(fn(?Payment $payment) => ($payment?->getTotal() ?? 0.0) . ' €')
->hideOnIndex()
->hideOnForm();
yield CollectionField::new('tickets')
->allowAdd()
->allowDelete()
->setEntryType(TicketType::class)
->setFormTypeOptions(['by_reference' => false])
->setTemplatePath('admin/customer_tickets.html.twig')
->hideOnIndex();
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->disable(Action::DELETE)
->setPermission(Action::NEW, 'ROLE_SUPER_ADMIN')
->setPermission(Action::EDIT, 'ROLE_SUPER_ADMIN');
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setPageTitle(Crud::PAGE_INDEX, 'Kunden')
->setPageTitle(Crud::PAGE_DETAIL, 'Kunden')
->showEntityActionsInlined();
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Payment;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
class PaymentCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Payment::class;
}
public function configureFields(string $pageName): iterable
{
yield AssociationField::new('customer', 'Kunde')
->setCrudController(CustomerCrudController::class)
->formatValue(fn($value, $payment) => $payment->getCustomer()->getEmail());
yield BooleanField::new('completed', 'Bezahlt')->renderAsSwitch(false);
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->disable(Action::DELETE)
->disable(Action::NEW)
->setPermission(Action::EDIT, 'ROLE_SUPER_ADMIN');
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setPageTitle(Crud::PAGE_INDEX, 'Zahlungen')
->setPageTitle(Crud::PAGE_DETAIL, 'Zahlung')
->showEntityActionsInlined();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
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\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route(path: '/admin/login', name: 'admin_login', methods: [Request::METHOD_GET, Request::METHOD_POST])]
public function login(AuthenticationUtils $authenticationUtils): Response
{
return $this->render('admin/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Ticket;
use App\Enum\FoodData;
use App\Enum\TicketData;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
class TicketCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Ticket::class;
}
public function configureFields(string $pageName): iterable
{
yield ChoiceField::new('type', 'Name')
->setChoices(TicketData::TYPES);
yield ChoiceField::new('foodType', 'Ernährung')
->setChoices(FoodData::TYPES);
yield AssociationField::new('customer', 'Kunde')
->setCrudController(CustomerCrudController::class)
->formatValue(fn(Customer $customer) => $customer->getEmail())
->hideOnForm();
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->disable(Action::DELETE)
->disable(Action::NEW)
->setPermission(Action::EDIT, 'ROLE_SUPER_ADMIN');
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setPageTitle(Crud::PAGE_INDEX, 'Tickets')
->setPageTitle(Crud::PAGE_DETAIL, 'Ticket')
->showEntityActionsInlined();
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Payment;
use App\Entity\Ticket;
use App\Repository\PaymentRepository;
use App\Repository\TicketRepository;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[AdminDashboard(routePath: '/admin', routeName: 'admin')]
class ViewController extends AbstractDashboardController
{
public function __construct(private readonly PaymentRepository $paymentRepository, private readonly TicketRepository $ticketRepository)
{
}
#[Route('/admin', name: 'admin')]
public function index(): Response
{
return $this->render('admin/dashboard.html.twig', [
'totalMoney' => $this->paymentRepository->getTotalMoneyMade(),
'foodData' => $this->ticketRepository->getFoodData(),
'totalTickets' => $this->ticketRepository->count(),
]);
}
public function configureDashboard(): Dashboard
{
return Dashboard::new()->setTitle('Abiball Admin Interface');
}
public function configureMenuItems(): iterable
{
yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');
yield MenuItem::section('Daten');
yield MenuItem::linkToCrud('Kunden', 'fa fa-users', Customer::class);
yield MenuItem::linkToCrud('Tickets', 'fa fa-ticket', Ticket::class);
yield MenuItem::linkToCrud('Zahlungen', 'fa fa-money', Payment::class);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\DataObjects;
class FoodData
{
public function __construct(
public int $totalMeat = 0,
public int $totalVegetarian = 0,
public int $totalVegan = 0,
) {
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\DataObjects;
use Symfony\Component\HttpFoundation\Request;
class LoginData
{
public function __construct(
public ?string $username = '',
public ?string $password = ''
) {
}
public static function fromRequest(Request $request): self
{
return new self(
$request->get('_username'),
$request->get('_password'),
);
}
}

View File

@ -8,7 +8,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
class Customer
class Customer implements \Stringable
{
#[ORM\Id]
#[ORM\GeneratedValue]
@ -149,4 +149,9 @@ class Customer
return $this;
}
public function __toString(): string
{
return $this->firstname . ' ' . $this->lastname;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Entity;
use App\Enum\TicketData;
use App\Repository\PaymentRepository;
use Doctrine\ORM\Mapping as ORM;
@ -63,4 +64,11 @@ class Payment
return $this;
}
public function getTotal(): float
{
return $this->customer->getTickets()->reduce(function (float $total, Ticket $ticket) {
return $total + TicketData::TICKET_DATA[$ticket->getType()]['price'];
}, 0);
}
}

View File

@ -2,11 +2,12 @@
namespace App\Entity;
use App\Enum\TicketData;
use App\Repository\TicketRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TicketRepository::class)]
class Ticket
class Ticket implements \Stringable
{
#[ORM\Id]
#[ORM\GeneratedValue]
@ -78,4 +79,9 @@ class Ticket
return $this;
}
public function __toString(): string
{
return TicketData::TICKET_DATA[$this->type]['name'];
}
}

18
src/Enum/FoodData.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Enum;
class FoodData
{
public const FOOD_DATA = [
1 => 'Mit Fleisch',
2 => 'Vegetarisch',
3 => 'Vegan'
];
public const TYPES = [
'Mit Fleisch' => 1,
'Vegetarisch' => 2,
'Vegan' => 3,
];
}

View File

@ -22,4 +22,11 @@ class TicketData
'price' => 0,
],
];
public const TYPES = [
'All-Inclusive Ticket' => 1,
'After-Show Ticket' => 2,
'Kind (6-12 Jahre)' => 3,
'Kind (0-6 Jahre)' => 4,
];
}

48
src/Form/TicketType.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Form;
use App\Entity\Ticket;
use App\Enum\FoodData;
use App\Enum\TicketData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TicketType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('type', ChoiceType::class, [
'label' => 'Ticket Type',
'choices' => TicketData::TYPES,
'row_attr' => [
'style' => 'flex: 1;',
],
])
->add('foodType', ChoiceType::class, [
'label' => 'Ernährung',
'choices' => FoodData::TYPES,
'row_attr' => [
'style' => 'flex: 1;',
],
])
->add('note', TextType::class, [
'label' => 'Notiz',
'required' => false,
'row_attr' => [
'style' => 'flex: 2;',
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Ticket::class,
]);
}
}

View File

@ -15,4 +15,15 @@ class PaymentRepository extends ServiceEntityRepository
{
parent::__construct($registry, Payment::class);
}
public function getTotalMoneyMade(): float
{
$all = $this->findAll();
$total = 0.0;
foreach ($all as $payment) {
$total += $payment->getTotal();
}
return $total;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Repository;
use App\DataObjects\FoodData;
use App\Entity\Ticket;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -15,4 +16,22 @@ class TicketRepository extends ServiceEntityRepository
{
parent::__construct($registry, Ticket::class);
}
public function getFoodData(): FoodData
{
$qb = $this->createQueryBuilder('t')
->select(
'SUM(CASE WHEN t.foodType = 1 THEN 1 ELSE 0 END) as totalMeat',
'SUM(CASE WHEN t.foodType = 2 THEN 1 ELSE 0 END) as totalVegetarian',
'SUM(CASE WHEN t.foodType = 3 THEN 1 ELSE 0 END) as totalVegan'
);
$result = $qb->getQuery()->getSingleResult();
return new FoodData(
(int) $result['totalMeat'],
(int) $result['totalVegetarian'],
(int) $result['totalVegan']
);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Security;
use App\DataObjects\LoginData;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class AdminPanelAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return str_starts_with($request->getRequestUri(), '/admin') && $request->isMethod(Request::METHOD_POST);
}
public function authenticate(Request $request): Passport
{
$data = LoginData::fromRequest($request);
if ($request->isMethod(Request::METHOD_POST) && (!$data->password || !$data->username)) {
throw new CustomUserMessageAuthenticationException();
}
return new Passport(new UserBadge($data->username), new PasswordCredentials($data->password), [new RememberMeBadge()]);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse('/admin');
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return null;
}
}

31
src/Twig/Ticket.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Twig;
use App\Enum\FoodData;
use App\Enum\TicketData;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class Ticket extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('food', $this->getFoodName(...)),
new TwigFilter('ticket', $this->getTicket(...)),
];
}
public function getFoodName(int $id): string
{
return FoodData::FOOD_DATA[$id] ?? 'N/A';
}
public function getTicket(int $id): array
{
return TicketData::TICKET_DATA[$id] ?? ['name' => 'N/A', 'price' => 'N/A'];
}
}