diff --git a/config/services.yaml b/config/services.yaml index 299f042..c88251b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -258,6 +258,19 @@ services: - '@App\BusProNet\Service\StatusRule\Selection1473StatusRule' - '@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: arguments: $httpClient: '@typo3.client' diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index a6d3818..d18c77a 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -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(), + ]); } } diff --git a/src/Dashboard/Contract/DashboardWidgetProviderInterface.php b/src/Dashboard/Contract/DashboardWidgetProviderInterface.php new file mode 100644 index 0000000..f63760f --- /dev/null +++ b/src/Dashboard/Contract/DashboardWidgetProviderInterface.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/src/Dashboard/Widget/OpenGroupBookingsWidgetProvider.php b/src/Dashboard/Widget/OpenGroupBookingsWidgetProvider.php new file mode 100644 index 0000000..4f4c56e --- /dev/null +++ b/src/Dashboard/Widget/OpenGroupBookingsWidgetProvider.php @@ -0,0 +1,93 @@ +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); + } +} diff --git a/src/Dashboard/Widget/PendingRoleApprovalsWidgetProvider.php b/src/Dashboard/Widget/PendingRoleApprovalsWidgetProvider.php new file mode 100644 index 0000000..6d4e7cd --- /dev/null +++ b/src/Dashboard/Widget/PendingRoleApprovalsWidgetProvider.php @@ -0,0 +1,84 @@ + 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(), + ]); + } +} diff --git a/src/Dashboard/Widget/RecentLogEntriesWidgetProvider.php b/src/Dashboard/Widget/RecentLogEntriesWidgetProvider.php new file mode 100644 index 0000000..c363e11 --- /dev/null +++ b/src/Dashboard/Widget/RecentLogEntriesWidgetProvider.php @@ -0,0 +1,78 @@ + 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, + ); + } +} diff --git a/src/Model/DashboardWidget.php b/src/Model/DashboardWidget.php new file mode 100644 index 0000000..6fe7c9e --- /dev/null +++ b/src/Model/DashboardWidget.php @@ -0,0 +1,27 @@ +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; + } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index c2e9355..2d4ed57 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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; + } } diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig index 45c3976..f05ab2c 100644 --- a/templates/admin/dashboard.html.twig +++ b/templates/admin/dashboard.html.twig @@ -1,10 +1,23 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -
- Coming soon, stay tuned. -
+ +{{ emptyText }}
+ {% endblock %} + {% else %} +