feat: admin dashboard widgets

This commit is contained in:
Björn Fromme
2026-08-25 16:31:45 +02:00
parent 578a21d327
commit 4e6aaba9ff
17 changed files with 808 additions and 6 deletions
+9 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Admin;
use App\Dashboard\DashboardWidgetRegistry;
use App\Security\Voter\AdministrativeAccessVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -13,9 +14,16 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
class DashboardController extends AbstractController
{
public function __construct(
private readonly DashboardWidgetRegistry $widgetRegistry,
) {
}
#[Route('/admin/dashboard', name: 'app_admin_dashboard')]
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;
}
+71
View File
@@ -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,
);
}
}
+27
View File
@@ -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,
) {
}
}
+22
View File
@@ -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,
) {
}
}
+19
View File
@@ -82,4 +82,23 @@ class LogEntryRepository extends ServiceEntityRepository
->getQuery()
->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;
}
}
+34
View File
@@ -8,6 +8,7 @@ use App\Entity\Groups\AccommodationBooking;
use App\Entity\User;
use App\Form\Model\Filter\UserFilterDto;
use App\Repository\Filter\AppliesListFiltersTrait;
use App\Security\Role;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@@ -88,4 +89,37 @@ class UserRepository extends ServiceEntityRepository
->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;
}
}