feat: admin dashboard widgets
This commit is contained in:
@@ -258,6 +258,19 @@ services:
|
|||||||
- '@App\BusProNet\Service\StatusRule\Selection1473StatusRule'
|
- '@App\BusProNet\Service\StatusRule\Selection1473StatusRule'
|
||||||
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
|
||||||
|
|
||||||
|
# Dashboard Widgets
|
||||||
|
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
|
||||||
|
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
|
||||||
|
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
|
||||||
|
|
||||||
|
# Dashboard Widget Registry
|
||||||
|
App\Dashboard\DashboardWidgetRegistry:
|
||||||
|
arguments:
|
||||||
|
$providers:
|
||||||
|
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
|
||||||
|
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
||||||
|
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
||||||
|
|
||||||
App\Service\CmsDataProvider:
|
App\Service\CmsDataProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$httpClient: '@typo3.client'
|
$httpClient: '@typo3.client'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller\Admin;
|
namespace App\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Dashboard\DashboardWidgetRegistry;
|
||||||
use App\Security\Voter\AdministrativeAccessVoter;
|
use App\Security\Voter\AdministrativeAccessVoter;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -13,9 +14,16 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
|
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
|
||||||
class DashboardController extends AbstractController
|
class DashboardController extends AbstractController
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DashboardWidgetRegistry $widgetRegistry,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/admin/dashboard', name: 'app_admin_dashboard')]
|
#[Route('/admin/dashboard', name: 'app_admin_dashboard')]
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
return $this->render('admin/dashboard.html.twig');
|
return $this->render('admin/dashboard.html.twig', [
|
||||||
|
'widgets' => $this->widgetRegistry->build(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dashboard\Contract;
|
||||||
|
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles one dashboard card.
|
||||||
|
*
|
||||||
|
* A provider never checks roles itself: it names the one role its widget requires and
|
||||||
|
* DashboardWidgetRegistry does the asking, so the security.yaml role_hierarchy stays the single
|
||||||
|
* place where "an admin is also a group admin" is written down.
|
||||||
|
*/
|
||||||
|
interface DashboardWidgetProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The role this widget requires, checked with isGranted() — so a widget requiring
|
||||||
|
* ROLE_GROUPS_MANAGER also appears for the roles above it in the hierarchy.
|
||||||
|
*/
|
||||||
|
public function getRequiredRole(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher priority widgets come first on the dashboard.
|
||||||
|
*/
|
||||||
|
public function getPriority(): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The assembled widget, or null to leave it out entirely.
|
||||||
|
*
|
||||||
|
* Null and a widget with no entries are different answers on purpose: null means this
|
||||||
|
* account has no business seeing the card at all, while an empty entry list means the card
|
||||||
|
* belongs here but has nothing to show right now, and renders its emptyText instead.
|
||||||
|
*/
|
||||||
|
public function build(): ?DashboardWidget;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dashboard;
|
||||||
|
|
||||||
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the dashboard for whoever is looking at it.
|
||||||
|
*
|
||||||
|
* Providers are sorted once, on construction, and asked in that order. Everything a provider is
|
||||||
|
* not allowed to show, or has nothing to say about, is dropped here rather than in the
|
||||||
|
* controller or the template — neither of which knows which widgets exist.
|
||||||
|
*/
|
||||||
|
class DashboardWidgetRegistry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var DashboardWidgetProviderInterface[]
|
||||||
|
*/
|
||||||
|
private array $sortedProviders;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param DashboardWidgetProviderInterface[] $providers
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
array $providers,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {
|
||||||
|
$this->sortedProviders = $this->sortProvidersByPriority($providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return DashboardWidget[]
|
||||||
|
*/
|
||||||
|
public function build(): array
|
||||||
|
{
|
||||||
|
$widgets = [];
|
||||||
|
|
||||||
|
foreach ($this->sortedProviders as $provider) {
|
||||||
|
if (false === $this->security->isGranted($provider->getRequiredRole())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$widget = $provider->build();
|
||||||
|
|
||||||
|
if (null !== $widget) {
|
||||||
|
$widgets[] = $widget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param DashboardWidgetProviderInterface[] $providers
|
||||||
|
*
|
||||||
|
* @return DashboardWidgetProviderInterface[]
|
||||||
|
*/
|
||||||
|
private function sortProvidersByPriority(array $providers): array
|
||||||
|
{
|
||||||
|
usort($providers, static fn (
|
||||||
|
DashboardWidgetProviderInterface $a,
|
||||||
|
DashboardWidgetProviderInterface $b,
|
||||||
|
): int => $b->getPriority() <=> $a->getPriority());
|
||||||
|
|
||||||
|
return $providers;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dashboard\Widget;
|
||||||
|
|
||||||
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
|
use App\Entity\Groups\AccommodationBooking;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Model\Filter\AccommodationBookingFilterDto;
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
use App\Model\DashboardWidgetEntry;
|
||||||
|
use App\Repository\Groups\AccommodationBookingRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group bookings that still need work, as an excerpt of the admin list.
|
||||||
|
*
|
||||||
|
* The excerpt is deliberately built from the list's own default filter rather than a second
|
||||||
|
* definition of "open": that keeps the statuses, the "nothing already over" date floor and the
|
||||||
|
* rule that a group manager sees only their own bookings in one place. Widen the list filter and
|
||||||
|
* this widget follows.
|
||||||
|
*/
|
||||||
|
class OpenGroupBookingsWidgetProvider implements DashboardWidgetProviderInterface
|
||||||
|
{
|
||||||
|
private const LIMIT = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AccommodationBookingRepository $bookingRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredRole(): string
|
||||||
|
{
|
||||||
|
return Role::GROUPS_MANAGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): ?DashboardWidget
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
$filter = AccommodationBookingFilterDto::defaults(
|
||||||
|
$this->security->isGranted(Role::GROUPS_ADMIN),
|
||||||
|
$user instanceof User ? $user : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var AccommodationBooking[] $bookings */
|
||||||
|
$bookings = $this->bookingRepository
|
||||||
|
->createFilteredQueryBuilder($filter)
|
||||||
|
->orderBy('booking.dateFrom', 'ASC')
|
||||||
|
->setMaxResults(self::LIMIT)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return new DashboardWidget(
|
||||||
|
'Offene Gruppenbuchungen',
|
||||||
|
array_map(fn (AccommodationBooking $booking): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||||
|
$this->label($booking),
|
||||||
|
$this->urlGenerator->generate('app_admin_accommodationbooking_show', ['id' => $booking->getId()]),
|
||||||
|
'house',
|
||||||
|
), $bookings),
|
||||||
|
'Keine offenen Gruppenbuchungen.',
|
||||||
|
$this->urlGenerator->generate('app_admin_accommodationbooking'),
|
||||||
|
'Alle Buchungen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group, or whoever booked when it has no name yet, plus the stay it is about.
|
||||||
|
*/
|
||||||
|
private function label(AccommodationBooking $booking): string
|
||||||
|
{
|
||||||
|
$name = $booking->getGroupName() ?? $booking->getLastName() ?? 'Ohne Namen';
|
||||||
|
|
||||||
|
$dateFrom = $booking->getDateFrom()?->format('d.m.Y');
|
||||||
|
$dateTo = $booking->getDateTo()?->format('d.m.Y');
|
||||||
|
|
||||||
|
if (null === $dateFrom || null === $dateTo) {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s (%s–%s)', $name, $dateFrom, $dateTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dashboard\Widget;
|
||||||
|
|
||||||
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\Model\Filter\AbstractListFilterDto;
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
use App\Model\DashboardWidgetEntry;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts waiting on an administrator to act.
|
||||||
|
*
|
||||||
|
* A nomination grants nothing until it is approved, and nothing else in the application
|
||||||
|
* announces that one is waiting — without this widget an administrator only finds out by
|
||||||
|
* opening the user list.
|
||||||
|
*/
|
||||||
|
class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInterface
|
||||||
|
{
|
||||||
|
private const LIMIT = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredRole(): string
|
||||||
|
{
|
||||||
|
return Role::ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): ?DashboardWidget
|
||||||
|
{
|
||||||
|
return new DashboardWidget(
|
||||||
|
'Offene Rollenfreigaben',
|
||||||
|
array_map(fn (User $user): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||||
|
$this->label($user),
|
||||||
|
$this->approvalUrl($user),
|
||||||
|
'user',
|
||||||
|
), $this->userRepository->findWithPendingRoles(self::LIMIT)),
|
||||||
|
'Keine offenen Rollenfreigaben.',
|
||||||
|
$this->urlGenerator->generate('app_admin_user'),
|
||||||
|
'Alle Benutzer',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function label(User $user): string
|
||||||
|
{
|
||||||
|
$nominated = Role::nominatedFrom($user->getRoles());
|
||||||
|
|
||||||
|
if ([] === $nominated) {
|
||||||
|
return $user->getDisplayName();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s: %s', $user->getDisplayName(), implode(', ', $nominated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user list, filtered down to this account.
|
||||||
|
*
|
||||||
|
* Approving happens in a modal that app_admin_user_permissions renders as a bare fragment,
|
||||||
|
* so it cannot be linked to directly. The filtered list is one click away from it and shows
|
||||||
|
* the nomination badge on the way. The query string is flat because the list filter forms
|
||||||
|
* declare no block prefix.
|
||||||
|
*/
|
||||||
|
private function approvalUrl(User $user): string
|
||||||
|
{
|
||||||
|
return $this->urlGenerator->generate('app_admin_user', [
|
||||||
|
AbstractListFilterDto::MARKER => 1,
|
||||||
|
'q' => $user->getEmail(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dashboard\Widget;
|
||||||
|
|
||||||
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
|
use App\Entity\LogEntry;
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
use App\Model\DashboardWidgetEntry;
|
||||||
|
use App\Repository\LogEntryRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the application has been up to lately.
|
||||||
|
*
|
||||||
|
* The entries themselves are display only: a log line is a report, not somewhere to go, and
|
||||||
|
* everything worth drilling into lives behind the log list's own filters — which is where the
|
||||||
|
* card's header link leads.
|
||||||
|
*/
|
||||||
|
class RecentLogEntriesWidgetProvider implements DashboardWidgetProviderInterface
|
||||||
|
{
|
||||||
|
private const LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log messages have no length limit worth relying on, and the widget renders one line per
|
||||||
|
* entry, so the message is cut to something a card can hold.
|
||||||
|
*/
|
||||||
|
private const MESSAGE_LENGTH = 60;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LogEntryRepository $logEntryRepository,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredRole(): string
|
||||||
|
{
|
||||||
|
return Role::ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): ?DashboardWidget
|
||||||
|
{
|
||||||
|
return new DashboardWidget(
|
||||||
|
'Neueste Logeinträge',
|
||||||
|
array_map(static fn (LogEntry $entry): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||||
|
self::label($entry),
|
||||||
|
null,
|
||||||
|
'document',
|
||||||
|
), $this->logEntryRepository->findLatest(self::LIMIT)),
|
||||||
|
'Keine Logeinträge vorhanden.',
|
||||||
|
$this->urlGenerator->generate('app_admin_log'),
|
||||||
|
'Alle Logeinträge',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function label(LogEntry $entry): string
|
||||||
|
{
|
||||||
|
$message = (string) $entry->getMessage();
|
||||||
|
|
||||||
|
if (mb_strlen($message) > self::MESSAGE_LENGTH) {
|
||||||
|
$message = mb_substr($message, 0, self::MESSAGE_LENGTH).'…';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%s %s: %s',
|
||||||
|
$entry->getCreatedAt()?->format('d.m.Y H:i') ?? '',
|
||||||
|
(string) $entry->getChannel(),
|
||||||
|
$message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One card on the admin dashboard, as its provider assembled it.
|
||||||
|
*
|
||||||
|
* Every widget has the same shape so the dashboard can render a list it knows nothing about.
|
||||||
|
* The action link is optional and lands in the card's header — typically "show the full list
|
||||||
|
* this widget is an excerpt of".
|
||||||
|
*/
|
||||||
|
final readonly class DashboardWidget
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param DashboardWidgetEntry[] $entries
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $title,
|
||||||
|
public array $entries,
|
||||||
|
public string $emptyText = 'Keine Einträge vorhanden.',
|
||||||
|
public ?string $actionUrl = null,
|
||||||
|
public ?string $actionLabel = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line in a dashboard widget: a label, optionally where it leads, and an optional icon.
|
||||||
|
*
|
||||||
|
* The url is generated by the widget provider rather than built in the template, so a provider
|
||||||
|
* is free to link anywhere without the dashboard learning its routes. Leaving it out makes the
|
||||||
|
* line display-only — a widget that reports rather than navigates.
|
||||||
|
*/
|
||||||
|
final readonly class DashboardWidgetEntry
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $label,
|
||||||
|
public ?string $url = null,
|
||||||
|
public ?string $icon = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,4 +82,23 @@ class LogEntryRepository extends ServiceEntityRepository
|
|||||||
->getQuery()
|
->getQuery()
|
||||||
->getResult();
|
->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent entries, newest first — the dashboard's "what just happened" excerpt.
|
||||||
|
*
|
||||||
|
* @return LogEntry[]
|
||||||
|
*/
|
||||||
|
public function findLatest(int $limit): array
|
||||||
|
{
|
||||||
|
/** @var LogEntry[] $entries */
|
||||||
|
$entries = $this->createQueryBuilder('log_entry')
|
||||||
|
->orderBy('log_entry.createdAt', 'DESC')
|
||||||
|
->addOrderBy('log_entry.id', 'DESC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Entity\Groups\AccommodationBooking;
|
|||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\Model\Filter\UserFilterDto;
|
use App\Form\Model\Filter\UserFilterDto;
|
||||||
use App\Repository\Filter\AppliesListFiltersTrait;
|
use App\Repository\Filter\AppliesListFiltersTrait;
|
||||||
|
use App\Security\Role;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\ORM\Query\Expr\Join;
|
use Doctrine\ORM\Query\Expr\Join;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
@@ -88,4 +89,37 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
->addOrderBy('u.email')
|
->addOrderBy('u.email')
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts carrying a nomination nobody has approved yet.
|
||||||
|
*
|
||||||
|
* Same reasoning as findGroupsStaff(): the roles are a JSON array column and no
|
||||||
|
* JSON_CONTAINS is registered, so each needle carries its quotes to stay anchored to a whole
|
||||||
|
* array entry. The markers are spelled out one by one rather than matched as '%_PENDING"%'
|
||||||
|
* because "_" is a single-character wildcard in LIKE, which would match more than markers.
|
||||||
|
*
|
||||||
|
* @return User[]
|
||||||
|
*/
|
||||||
|
public function findWithPendingRoles(int $limit): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('u');
|
||||||
|
$orX = $qb->expr()->orX();
|
||||||
|
|
||||||
|
foreach (Role::ADMINISTRATIVE as $index => $role) {
|
||||||
|
$parameter = 'pending'.$index;
|
||||||
|
$orX->add('u.roles LIKE :'.$parameter);
|
||||||
|
$qb->setParameter($parameter, '%"'.Role::pending($role).'"%');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User[] $users */
|
||||||
|
$users = $qb
|
||||||
|
->andWhere($orX)
|
||||||
|
->orderBy('u.lastLoginAt', 'DESC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return $users;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
{% extends 'layout_admin.html.twig' %}
|
{% extends 'layout_admin.html.twig' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>
|
<twig:page:heading>
|
||||||
MyE&P Admin
|
MyE&P Admin
|
||||||
</h1>
|
</twig:page:heading>
|
||||||
<p>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
Coming soon, stay tuned.
|
|
||||||
</p>
|
{% for widget in widgets %}
|
||||||
|
<twig:dashboard:widget title="{{ widget.title }}" emptyText="{{ widget.emptyText }}">
|
||||||
|
<twig:block name="actions">
|
||||||
|
{% if widget.actionUrl %}
|
||||||
|
<a href="{{ widget.actionUrl }}" class="text-sm font-normal text-primary">{{ widget.actionLabel }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</twig:block>
|
||||||
|
{% for entry in widget.entries %}
|
||||||
|
<twig:dashboard:entry icon="{{ entry.icon }}" url="{{ entry.url }}">{{ entry.label }}</twig:dashboard:entry>
|
||||||
|
{% endfor %}
|
||||||
|
</twig:dashboard:widget>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% set url = url ?? null %}
|
||||||
|
{% set icon = icon ?? null %}
|
||||||
|
{% set body %}
|
||||||
|
{% if icon %}{{ icon(icon, 'w-4 h-4 shrink-0') }}{% endif %}
|
||||||
|
<span class="whitespace-nowrap">{{ block('content') }}</span>
|
||||||
|
{% endset %}
|
||||||
|
<li class="py-2 first:pt-0 last:pb-0 overflow-x-hidden">
|
||||||
|
{% if url %}
|
||||||
|
<a href="{{ url }}" {{ attributes.defaults({class: 'flex items-center space-x-1'}).without('url', 'icon') }}>{{ body }}</a>
|
||||||
|
{% else %}
|
||||||
|
<div {{ attributes.defaults({class: 'flex items-center space-x-1'}).without('url', 'icon') }}>{{ body }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% set emptyText = emptyText ?? 'Keine Einträge vorhanden.' %}
|
||||||
|
{% set entries %}{% block content %}{% endblock %}{% endset %}
|
||||||
|
<div {{ attributes.defaults({class: 'bg-gray-100 border border-gray-200 rounded-md px-4 py-2'}).without('title', 'emptyText') }}>
|
||||||
|
<h2 class="flex justify-between pb-2">
|
||||||
|
<span class="font-bold text-lg">{{ title }}</span>
|
||||||
|
{% block actions %}{% endblock %}
|
||||||
|
</h2>
|
||||||
|
{% if entries|trim is empty %}
|
||||||
|
{% block empty %}
|
||||||
|
<p class="py-2 text-sm text-gray-500">{{ emptyText }}</p>
|
||||||
|
{% endblock %}
|
||||||
|
{% else %}
|
||||||
|
<ul class="divide-y divide-gray-200">{{ entries }}</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Dashboard;
|
||||||
|
|
||||||
|
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||||
|
use App\Dashboard\DashboardWidgetRegistry;
|
||||||
|
use App\Model\DashboardWidget;
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
class DashboardWidgetRegistryTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSkipsProvidersWhoseRoleIsNotGranted(): void
|
||||||
|
{
|
||||||
|
$provider = $this->provider(Role::ADMIN, 100, new DashboardWidget('Nur für Admins', []));
|
||||||
|
|
||||||
|
$registry = new DashboardWidgetRegistry([$provider], $this->securityGranting([]));
|
||||||
|
|
||||||
|
$this->assertSame([], $registry->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsProvidersWhoseRoleIsGranted(): void
|
||||||
|
{
|
||||||
|
$widget = new DashboardWidget('Gruppenbuchungen', []);
|
||||||
|
$provider = $this->provider(Role::GROUPS_MANAGER, 100, $widget);
|
||||||
|
|
||||||
|
$registry = new DashboardWidgetRegistry([$provider], $this->securityGranting([Role::GROUPS_MANAGER]));
|
||||||
|
|
||||||
|
$this->assertSame([$widget], $registry->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hierarchy is security.yaml's business, not the registry's: whatever isGranted() says
|
||||||
|
* about ROLE_GROUPS_MANAGER is what an admin gets, without the registry knowing why.
|
||||||
|
*/
|
||||||
|
public function testRoleIsResolvedByTheSecurityCheckerAlone(): void
|
||||||
|
{
|
||||||
|
$widget = new DashboardWidget('Gruppenbuchungen', []);
|
||||||
|
$provider = $this->provider(Role::GROUPS_MANAGER, 100, $widget);
|
||||||
|
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->expects($this->once())
|
||||||
|
->method('isGranted')
|
||||||
|
->with(Role::GROUPS_MANAGER)
|
||||||
|
->willReturn(true)
|
||||||
|
;
|
||||||
|
|
||||||
|
$registry = new DashboardWidgetRegistry([$provider], $security);
|
||||||
|
|
||||||
|
$this->assertSame([$widget], $registry->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDropsProvidersReturningNullButKeepsEmptyWidgets(): void
|
||||||
|
{
|
||||||
|
$empty = new DashboardWidget('Leer, aber da', []);
|
||||||
|
|
||||||
|
$registry = new DashboardWidgetRegistry([
|
||||||
|
$this->provider(Role::ADMIN, 100, null),
|
||||||
|
$this->provider(Role::ADMIN, 90, $empty),
|
||||||
|
], $this->securityGranting([Role::ADMIN]));
|
||||||
|
|
||||||
|
$this->assertSame([$empty], $registry->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsWidgetsInPriorityOrder(): void
|
||||||
|
{
|
||||||
|
$low = new DashboardWidget('Zuletzt', []);
|
||||||
|
$high = new DashboardWidget('Zuerst', []);
|
||||||
|
|
||||||
|
$registry = new DashboardWidgetRegistry([
|
||||||
|
$this->provider(Role::ADMIN, 10, $low),
|
||||||
|
$this->provider(Role::ADMIN, 100, $high),
|
||||||
|
], $this->securityGranting([Role::ADMIN]));
|
||||||
|
|
||||||
|
$this->assertSame([$high, $low], $registry->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function provider(string $role, int $priority, ?DashboardWidget $widget): DashboardWidgetProviderInterface
|
||||||
|
{
|
||||||
|
$provider = $this->createMock(DashboardWidgetProviderInterface::class);
|
||||||
|
$provider->method('getRequiredRole')->willReturn($role);
|
||||||
|
$provider->method('getPriority')->willReturn($priority);
|
||||||
|
$provider->method('build')->willReturn($widget);
|
||||||
|
|
||||||
|
return $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $grantedRoles
|
||||||
|
*/
|
||||||
|
private function securityGranting(array $grantedRoles): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturnCallback(
|
||||||
|
static fn (mixed $role): bool => \in_array($role, $grantedRoles, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Dashboard;
|
||||||
|
|
||||||
|
use App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
class PendingRoleApprovalsWidgetProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEntryNamesTheAccountAndWhatItIsNominatedFor(): void
|
||||||
|
{
|
||||||
|
$user = (new User('[email protected]'))
|
||||||
|
->setFirstName('Rita')
|
||||||
|
->setLastName('Vorschlag')
|
||||||
|
->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)])
|
||||||
|
;
|
||||||
|
|
||||||
|
$widget = $this->provider([$user])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertCount(1, $widget->entries);
|
||||||
|
$this->assertStringContainsString('Rita Vorschlag', $widget->entries[0]->label);
|
||||||
|
$this->assertStringContainsString(Role::labels()[Role::GROUPS_ADMIN], $widget->entries[0]->label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The approval modal cannot be linked to directly, so the entry leads to the user list
|
||||||
|
* filtered down to that account — which needs the filter marker to bind at all.
|
||||||
|
*/
|
||||||
|
public function testEntryLinksToTheUserListFilteredToThatAccount(): void
|
||||||
|
{
|
||||||
|
$user = (new User('[email protected]'))
|
||||||
|
->setRoles([Role::pending(Role::ADMIN)])
|
||||||
|
;
|
||||||
|
|
||||||
|
$widget = $this->provider([$user])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertSame('/admin/user?f=1&q=nominee%40example.com', $widget->entries[0]->url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRendersAsAnEmptyCardRatherThanDisappearingWhenNothingIsPending(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertSame([], $widget->entries);
|
||||||
|
$this->assertSame('Keine offenen Rollenfreigaben.', $widget->emptyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequiresTheRoleThatMayActuallyApprove(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(Role::ADMIN, $this->provider([])->getRequiredRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param User[] $pending
|
||||||
|
*/
|
||||||
|
private function provider(array $pending): PendingRoleApprovalsWidgetProvider
|
||||||
|
{
|
||||||
|
$repository = $this->createMock(UserRepository::class);
|
||||||
|
$repository->method('findWithPendingRoles')->willReturn($pending);
|
||||||
|
|
||||||
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->method('generate')->willReturnCallback(
|
||||||
|
static function (string $route, array $parameters = []): string {
|
||||||
|
$path = '/'.str_replace('_', '/', substr($route, \strlen('app_')));
|
||||||
|
|
||||||
|
return [] === $parameters ? $path : $path.'?'.http_build_query($parameters);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new PendingRoleApprovalsWidgetProvider($repository, $urlGenerator);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Dashboard;
|
||||||
|
|
||||||
|
use App\Dashboard\Widget\RecentLogEntriesWidgetProvider;
|
||||||
|
use App\Entity\LogEntry;
|
||||||
|
use App\Repository\LogEntryRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
|
class RecentLogEntriesWidgetProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEntriesStayDisplayOnly(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([$this->logEntry('bpn', 'Verbindung wiederhergestellt')])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertNull($widget->entries[0]->url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeaderLinksToTheFullLog(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([$this->logEntry('bpn', 'Verbindung wiederhergestellt')])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertSame('/admin/log', $widget->actionUrl);
|
||||||
|
$this->assertSame('Alle Logeinträge', $widget->actionLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLabelNamesWhenWhereAndWhat(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([$this->logEntry('auth', 'Anmeldung fehlgeschlagen')])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertSame('24.08.2026 09:30 auth: Anmeldung fehlgeschlagen', $widget->entries[0]->label);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLongMessagesAreCutToFitOneLine(): void
|
||||||
|
{
|
||||||
|
$widget = $this->provider([$this->logEntry('core', str_repeat('a', 80))])->build();
|
||||||
|
|
||||||
|
$this->assertNotNull($widget);
|
||||||
|
$this->assertStringEndsWith(str_repeat('a', 60).'…', $widget->entries[0]->label);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAsksForFiveEntriesAndRequiresAdmin(): void
|
||||||
|
{
|
||||||
|
$repository = $this->createMock(LogEntryRepository::class);
|
||||||
|
$repository->expects($this->once())
|
||||||
|
->method('findLatest')
|
||||||
|
->with(5)
|
||||||
|
->willReturn([])
|
||||||
|
;
|
||||||
|
|
||||||
|
$provider = new RecentLogEntriesWidgetProvider($repository, $this->urlGenerator());
|
||||||
|
|
||||||
|
$this->assertSame(Role::ADMIN, $provider->getRequiredRole());
|
||||||
|
$this->assertNotNull($provider->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param LogEntry[] $entries
|
||||||
|
*/
|
||||||
|
private function provider(array $entries): RecentLogEntriesWidgetProvider
|
||||||
|
{
|
||||||
|
$repository = $this->createMock(LogEntryRepository::class);
|
||||||
|
$repository->method('findLatest')->willReturn($entries);
|
||||||
|
|
||||||
|
return new RecentLogEntriesWidgetProvider($repository, $this->urlGenerator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function urlGenerator(): UrlGeneratorInterface
|
||||||
|
{
|
||||||
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->method('generate')->willReturnCallback(
|
||||||
|
static fn (string $route): string => '/'.str_replace('_', '/', substr($route, \strlen('app_'))),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $urlGenerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logEntry(string $channel, string $message): LogEntry
|
||||||
|
{
|
||||||
|
return (new LogEntry($channel, $message))
|
||||||
|
->setCreatedAt(new \DateTimeImmutable('2026-08-24 09:30:00'))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user