From 0070976e342d0dc1726a66c591c0ff443649f390 Mon Sep 17 00:00:00 2001
From: philipp lang <philipp@aweos.de>
Date: Tue, 24 Sep 2024 01:26:08 +0200
Subject: [PATCH] Add Service Provider

---
 composer.json                                 |  1 +
 config/app.php                                |  2 +-
 .../Base/BaseServiceProvider.php              | 13 ++--
 modules/Base/Components/Page/Header.php       | 34 +++++++++++
 modules/Base/Components/Page/Layout.php       | 54 ++++++++++++++++
 .../Base/Components}/Page/MenuEntry.php       |  2 +-
 .../Base/Components}/Page/Sidebar.php         |  2 +-
 .../Base/Components}/Ui/Box.php               |  2 +-
 .../Base/Components}/Ui/Sprite.php            |  2 +-
 modules/Base/tests/PageLayoutTest.php         | 34 +++++++++++
 modules/Dashboard/Block.php                   | 12 ++++
 .../Components/DashboardComponent.php         | 35 +++++++++++
 modules/Dashboard/DashboardFactory.php        | 41 +++++++++++++
 .../Dashboard/DashboardServiceProvider.php    | 32 ++++++++++
 .../tests/DashboardComponentTest.php          | 47 ++++++++++++++
 modules/Invoice/MemberPaymentBlock.php        | 37 +++++++++++
 modules/Member/AgeGroupCountBlock.php         | 61 +++++++++++++++++++
 modules/Member/TestersBlock.php               | 43 +++++++++++++
 modules/Prevention/EfzPendingBlock.php        | 44 +++++++++++++
 modules/Prevention/PsPendingBlock.php         | 50 +++++++++++++++
 .../views/components/layouts/app.blade.php    | 20 ++++++
 21 files changed, 559 insertions(+), 9 deletions(-)
 rename app/Providers/LivewireServiceProvider.php => modules/Base/BaseServiceProvider.php (75%)
 create mode 100644 modules/Base/Components/Page/Header.php
 create mode 100644 modules/Base/Components/Page/Layout.php
 rename {app/View => modules/Base/Components}/Page/MenuEntry.php (94%)
 rename {app/View => modules/Base/Components}/Page/Sidebar.php (98%)
 rename {app/View => modules/Base/Components}/Ui/Box.php (96%)
 rename {app/View => modules/Base/Components}/Ui/Sprite.php (90%)
 create mode 100644 modules/Base/tests/PageLayoutTest.php
 create mode 100644 modules/Dashboard/Block.php
 create mode 100644 modules/Dashboard/Components/DashboardComponent.php
 create mode 100644 modules/Dashboard/DashboardFactory.php
 create mode 100644 modules/Dashboard/DashboardServiceProvider.php
 create mode 100644 modules/Dashboard/tests/DashboardComponentTest.php
 create mode 100644 modules/Invoice/MemberPaymentBlock.php
 create mode 100644 modules/Member/AgeGroupCountBlock.php
 create mode 100644 modules/Member/TestersBlock.php
 create mode 100644 modules/Prevention/EfzPendingBlock.php
 create mode 100644 modules/Prevention/PsPendingBlock.php
 create mode 100644 resources/views/components/layouts/app.blade.php

diff --git a/composer.json b/composer.json
index c86b8089..cba32f4a 100644
--- a/composer.json
+++ b/composer.json
@@ -114,6 +114,7 @@
     "autoload-dev": {
         "psr-4": {
             "Modules\\Dashboard\\Tests\\": "modules/dashboard/tests/",
+            "Modules\\Base\\Tests\\": "modules/base/tests/",
             "Tests\\": "tests/",
             "Zoomyboy\\LaravelNami\\Tests\\": "packages/laravel-nami/tests/"
         }
diff --git a/config/app.php b/config/app.php
index a763ad6e..f567ace3 100644
--- a/config/app.php
+++ b/config/app.php
@@ -178,7 +178,7 @@ return [
         // App\Dashboard\DashboardServiceProvider::class,
         App\Providers\PluginServiceProvider::class,
         Modules\Dashboard\DashboardServiceProvider::class,
-        App\Providers\LivewireServiceProvider::class,
+        Modules\Base\BaseServiceProvider::class,
     ],
 
     /*
diff --git a/app/Providers/LivewireServiceProvider.php b/modules/Base/BaseServiceProvider.php
similarity index 75%
rename from app/Providers/LivewireServiceProvider.php
rename to modules/Base/BaseServiceProvider.php
index 880ad376..0cb597eb 100644
--- a/app/Providers/LivewireServiceProvider.php
+++ b/modules/Base/BaseServiceProvider.php
@@ -1,11 +1,12 @@
 <?php
 
-namespace App\Providers;
+namespace Modules\Base;
 
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\Blade;
 use Illuminate\View\ComponentAttributeBag;
 use Livewire\Livewire;
+use Modules\Base\Components\Page\Sidebar;
 use Modules\Dashboard\DashboardFactory;
 use Modules\Invoice\MemberPaymentBlock;
 use Modules\Member\AgeGroupCountBlock;
@@ -13,15 +14,16 @@ use Modules\Member\TestersBlock;
 use Modules\Prevention\EfzPendingBlock;
 use Modules\Prevention\PsPendingBlock;
 
-class LivewireServiceProvider extends ServiceProvider
+class BaseServiceProvider extends ServiceProvider
 {
     /**
      * Register services.
      */
     public function register(): void
     {
-        Blade::componentNamespace('App\\View\\Ui', 'ui');
-        Blade::componentNamespace('App\\View\\Page', 'page');
+        Blade::componentNamespace('Modules\\Base\\Components\\Ui', 'ui');
+        Blade::componentNamespace('Modules\\Base\\Components\\Page', 'page');
+
 
         app(DashboardFactory::class)->register(AgeGroupCountBlock::class);
         app(DashboardFactory::class)->register(MemberPaymentBlock::class);
@@ -29,6 +31,8 @@ class LivewireServiceProvider extends ServiceProvider
         app(DashboardFactory::class)->register(EfzPendingBlock::class);
         app(DashboardFactory::class)->register(PsPendingBlock::class);
 
+        Livewire::component('page.sidebar', Sidebar::class);
+
         ComponentAttributeBag::macro('mergeWhen', function ($condition, $key, $attributes) {
             /** @var ComponentAttributeBag */
             $self = $this;
@@ -41,6 +45,7 @@ class LivewireServiceProvider extends ServiceProvider
      */
     public function boot(): void
     {
+        Livewire::component('pagesidebar', Sidebar::class);
         //
     }
 }
diff --git a/modules/Base/Components/Page/Header.php b/modules/Base/Components/Page/Header.php
new file mode 100644
index 00000000..960223ad
--- /dev/null
+++ b/modules/Base/Components/Page/Header.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Modules\Base\Components\Page;
+
+use Illuminate\View\Component;
+
+class Header extends Component
+{
+
+    public function __construct(public string $title, public bool $closeable = false)
+    {
+    }
+
+    public function render()
+    {
+        return <<<'HTML'
+            <div class="h-16 px-6 flex items-center justify-between border-b border-solid border-gray-600 group-[.is-bright]:border-gray-500">
+                <div class="flex items-center space-x-2">
+                    {{ $beforeTitle ?? ''}}
+                    <span class="text-sm md:text-xl font-semibold leading-none text-white">{{ $title }}</span>
+                    {{ $toolbar ?? '' }}
+                </div>
+                <div class="flex items-center space-x-4 ml-2">
+                    @if ($closeable)
+                    <a href="#" class="btn label btn-primary-light icon" wire:click="close">
+                        <ui-sprite class="w-3 h-3" src="close"></ui-sprite>
+                    </a>
+                    @endif
+                    {{ $right ?? '' }}
+                </div>
+            </div>
+        HTML;
+    }
+}
diff --git a/modules/Base/Components/Page/Layout.php b/modules/Base/Components/Page/Layout.php
new file mode 100644
index 00000000..d0cb95d8
--- /dev/null
+++ b/modules/Base/Components/Page/Layout.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Modules\Base\Components\Page;
+
+use Illuminate\View\Component;
+
+class Layout extends Component
+{
+
+    public function __construct()
+    {
+    }
+
+    public function userName(): string
+    {
+        return auth()->user()->firstname . ' ' . auth()->user()->lastname;
+    }
+
+    public function userAvatar(): string
+    {
+        return auth()->user()->getGravatarUrl();
+    }
+
+    public function render()
+    {
+        return <<<'HTML'
+            <div class="grow bg-gray-900 flex flex-col transition-all ml-56" :class="{'ml-56': menuStore.visible, 'ml-0': !menuStore.visible}">
+                <x-page::header title="{{ session()->get('title') }}">
+                    <x-slot:beforeTitle>
+                        <a href="#" class="mr-2 lg:hidden" @click.prevent="menuStore.toggle()">
+                            <ui-sprite src="menu" class="text-gray-100 w-5 h-5"></ui-sprite>
+                        </a>
+                    </x-slot:beforeTitle>
+                    <x-slot:toolbar>
+                        {{ $toolbar ?? ''}}
+                    </x-slot:toolbar>
+                    <x-slot:right>
+                        {{ $right ?? '' }}
+                        <div class="flex items-center space-x-2">
+                            <div class="rounded-full overflow-hidden border-2 border-solid border-gray-300">
+                                <img src="{{ $userAvatar() }}" class="w-8 h-8 object-cover" />
+                            </div>
+                            <div class="text-gray-300"">{{ $userName() }}</div>
+                        </div>
+                    </x-slot:right>
+                </x-page::header>
+
+                <div :class="pageClass" class="grow flex flex-col">
+                    {{ $slot }}
+                </div>
+            </div>
+        HTML;
+    }
+}
diff --git a/app/View/Page/MenuEntry.php b/modules/Base/Components/Page/MenuEntry.php
similarity index 94%
rename from app/View/Page/MenuEntry.php
rename to modules/Base/Components/Page/MenuEntry.php
index 260fc395..85ea81df 100644
--- a/app/View/Page/MenuEntry.php
+++ b/modules/Base/Components/Page/MenuEntry.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace App\View\Page;
+namespace Modules\Base\Components\Page;
 
 use Illuminate\View\Component;
 
diff --git a/app/View/Page/Sidebar.php b/modules/Base/Components/Page/Sidebar.php
similarity index 98%
rename from app/View/Page/Sidebar.php
rename to modules/Base/Components/Page/Sidebar.php
index 946c8f2f..deab75e4 100644
--- a/app/View/Page/Sidebar.php
+++ b/modules/Base/Components/Page/Sidebar.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace App\View\Page;
+namespace Modules\Base\Components\Page;
 
 use Livewire\Component;
 
diff --git a/app/View/Ui/Box.php b/modules/Base/Components/Ui/Box.php
similarity index 96%
rename from app/View/Ui/Box.php
rename to modules/Base/Components/Ui/Box.php
index 03e51808..747a2701 100644
--- a/app/View/Ui/Box.php
+++ b/modules/Base/Components/Ui/Box.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace App\View\Ui;
+namespace Modules\Base\Components\Ui;
 
 use Illuminate\View\Component;
 
diff --git a/app/View/Ui/Sprite.php b/modules/Base/Components/Ui/Sprite.php
similarity index 90%
rename from app/View/Ui/Sprite.php
rename to modules/Base/Components/Ui/Sprite.php
index 679eaf18..41cd9687 100644
--- a/app/View/Ui/Sprite.php
+++ b/modules/Base/Components/Ui/Sprite.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace App\View\Ui;
+namespace Modules\Base\Components\Ui;
 
 use Illuminate\View\Component;
 
diff --git a/modules/Base/tests/PageLayoutTest.php b/modules/Base/tests/PageLayoutTest.php
new file mode 100644
index 00000000..6f2be204
--- /dev/null
+++ b/modules/Base/tests/PageLayoutTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Modules\Dashboard\Tests;
+
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Livewire\Component;
+use Livewire\Livewire;
+use Tests\TestCase;
+
+uses(DatabaseTransactions::class);
+uses(TestCase::class);
+
+it('renders successfully', function () {
+    $this->login()->loginNami();
+
+    Livewire::test(DummyComponent::class)
+        ->assertSee('Testcontent')
+        ->assertSee(auth()->user()->lastname);
+});
+
+class DummyComponent extends Component
+{
+
+    public function render(): string
+    {
+        return <<<'HTML'
+            <div>
+                <x-page::layout>
+                    Testcontent
+                </x-page::layout>
+            </div>
+        HTML;
+    }
+}
diff --git a/modules/Dashboard/Block.php b/modules/Dashboard/Block.php
new file mode 100644
index 00000000..3376e3f2
--- /dev/null
+++ b/modules/Dashboard/Block.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Modules\Dashboard;
+
+use Livewire\Component;
+
+abstract class Block extends Component
+{
+    abstract protected function title(): string;
+
+    abstract public function render(): string;
+}
diff --git a/modules/Dashboard/Components/DashboardComponent.php b/modules/Dashboard/Components/DashboardComponent.php
new file mode 100644
index 00000000..a57f8504
--- /dev/null
+++ b/modules/Dashboard/Components/DashboardComponent.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Modules\Dashboard\Components;
+
+use Livewire\Component;
+use Modules\Dashboard\DashboardFactory;
+
+class DashboardComponent extends Component
+{
+
+    private array $blocks = [];
+
+    public function mount(DashboardFactory $factory): void
+    {
+        session()->put('menu', 'dashboard');
+        session()->put('title', 'Dashboard');
+
+        $this->blocks = $factory->load();
+    }
+
+    public function render(): string
+    {
+        return <<<'HTML'
+            <x-page::layout>
+                <div class="gap-6 md:grid-cols-2 xl:grid-cols-4 grid p-6">
+                    @foreach($this->blocks as $block)
+                        <x-ui::box title="{{$block->title()}}" :second="true">
+                            <livewire:dynamic-component is="{{ get_class($block) }}" lazy></livewire:dynamic-component>
+                        </x-ui::box>
+                    @endforeach
+                </div>
+            </x-page::layout>
+        HTML;
+    }
+}
diff --git a/modules/Dashboard/DashboardFactory.php b/modules/Dashboard/DashboardFactory.php
new file mode 100644
index 00000000..03fa0789
--- /dev/null
+++ b/modules/Dashboard/DashboardFactory.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Modules\Dashboard;
+
+use Livewire\Livewire;
+
+class DashboardFactory
+{
+    /**
+     * @var array<int, class-string<Block>>
+     */
+    private array $blocks = [];
+
+    /**
+     * @return array<int, Block>
+     */
+    public function load(): array
+    {
+        return collect($this->blocks)->map(fn ($block) => app($block))->toArray();
+    }
+
+    /**
+     * @param class-string<Block> $block
+     */
+    public function register(string $block): self
+    {
+        $this->blocks[] = $block;
+
+        $componentName = str($block)->replace('\\', '.')->explode('.')->map(fn ($part) => str($part)->lcfirst()->kebab())->implode('.');
+        Livewire::component($componentName, $block);
+
+        return $this;
+    }
+
+    public function purge(): self
+    {
+        $this->blocks = [];
+
+        return $this;
+    }
+}
diff --git a/modules/Dashboard/DashboardServiceProvider.php b/modules/Dashboard/DashboardServiceProvider.php
new file mode 100644
index 00000000..25613e67
--- /dev/null
+++ b/modules/Dashboard/DashboardServiceProvider.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Modules\Dashboard;
+
+use Illuminate\Routing\Router;
+use Illuminate\Support\ServiceProvider;
+use Modules\Dashboard\Components\DashboardComponent;
+
+class DashboardServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     *
+     * @return void
+     */
+    public function register()
+    {
+        app()->singleton(DashboardFactory::class, fn () => new DashboardFactory());
+    }
+
+    /**
+     * Bootstrap services.
+     *
+     * @return void
+     */
+    public function boot()
+    {
+        app(Router::class)->middleware(['web', 'auth:web'])->group(function ($router) {
+            $router->get('/', DashboardComponent::class)->name('home');
+        });
+    }
+}
diff --git a/modules/Dashboard/tests/DashboardComponentTest.php b/modules/Dashboard/tests/DashboardComponentTest.php
new file mode 100644
index 00000000..d2e38264
--- /dev/null
+++ b/modules/Dashboard/tests/DashboardComponentTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Modules\Dashboard\Tests;
+
+use Modules\Dashboard\Block;
+use Modules\Dashboard\Components\DashboardComponent;
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Livewire\Livewire;
+use Modules\Dashboard\DashboardFactory;
+use Tests\TestCase;
+
+uses(DatabaseTransactions::class);
+uses(TestCase::class);
+
+it('renders successfully', function () {
+    $this->login()->loginNami();
+
+    app(DashboardFactory::class)->purge();
+    app(DashboardFactory::class)->register(ExampleBlock::class);
+
+    Livewire::test(DashboardComponent::class)
+        ->assertSee('ExampleTitle');
+});
+
+it('renders page successfully', function () {
+    $this->login()->loginNami();
+
+    $this->get('/')->assertOk()->assertSee('Dashboard');
+});
+
+class ExampleBlock extends Block
+{
+
+    public function title(): string
+    {
+        return 'ExampleTitle';
+    }
+
+    public function render(): string
+    {
+        return <<<'HTML'
+            <div>
+                Example Content
+            </div>
+        HTML;
+    }
+}
diff --git a/modules/Invoice/MemberPaymentBlock.php b/modules/Invoice/MemberPaymentBlock.php
new file mode 100644
index 00000000..ecd0c7db
--- /dev/null
+++ b/modules/Invoice/MemberPaymentBlock.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Modules\Invoice;
+
+use Modules\Dashboard\Block;
+use App\Invoice\Models\InvoicePosition;
+use App\Member\Member;
+
+class MemberPaymentBlock extends Block
+{
+    /**
+     * @return array<string, string|int>
+     */
+    public function data(): array
+    {
+        $amount = InvoicePosition::whereHas('invoice', fn ($query) => $query->whereNeedsPayment())
+            ->selectRaw('sum(price) AS price')
+            ->first();
+        $members = Member::whereHasPendingPayment()->count();
+
+        return [
+            'members' => $members,
+            'total_members' => Member::count(),
+            'amount' => number_format((int) $amount->price / 100, 2, ',', '.') . ' €',
+        ];
+    }
+
+    public function title(): string
+    {
+        return 'Ausstehende Mitgliedsbeiträge';
+    }
+
+    public function render(): string
+    {
+        return '<div></div>';
+    }
+}
diff --git a/modules/Member/AgeGroupCountBlock.php b/modules/Member/AgeGroupCountBlock.php
new file mode 100644
index 00000000..48d88d79
--- /dev/null
+++ b/modules/Member/AgeGroupCountBlock.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Modules\Member;
+
+use Modules\Dashboard\Block;
+use App\Member\Membership;
+use Illuminate\Database\Eloquent\Builder;
+
+class AgeGroupCountBlock extends Block
+{
+    /**
+     * @return Builder<Membership>
+     */
+    public function memberQuery(): Builder
+    {
+        return Membership::select('subactivities.slug', 'subactivities.name')
+            ->selectRaw('COUNT(member_id) AS count')
+            ->join('activities', 'memberships.activity_id', 'activities.id')
+            ->join('subactivities', 'memberships.subactivity_id', 'subactivities.id')
+            ->isAgeGroup()
+            ->isMember()
+            ->active()
+            ->groupBy('subactivities.slug', 'subactivities.name')
+            ->orderBy('subactivity_id');
+    }
+
+    /**
+     * @return Builder<Membership>
+     */
+    public function leaderQuery(): Builder
+    {
+        return Membership::selectRaw('"leiter" AS slug, "Leiter" AS name, COUNT(member_id) AS count')
+            ->join('activities', 'memberships.activity_id', 'activities.id')
+            ->join('subactivities', 'memberships.subactivity_id', 'subactivities.id')
+            ->active()
+            ->isLeader();
+    }
+
+    protected function data(): array
+    {
+        return [
+            'groups' => [
+                ...$this->memberQuery()->get()->toArray(),
+                ...$this->leaderQuery()->get()->toArray(),
+            ],
+        ];
+    }
+
+    public function title(): string
+    {
+        return 'Gruppierungs-Verteilung';
+    }
+
+    public function render(): string
+    {
+        return '<div>
+
+            lalala
+            </div>';
+    }
+}
diff --git a/modules/Member/TestersBlock.php b/modules/Member/TestersBlock.php
new file mode 100644
index 00000000..e8fbd71e
--- /dev/null
+++ b/modules/Member/TestersBlock.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Modules\Member;
+
+use Modules\Dashboard\Block;
+use App\Member\Member;
+use Illuminate\Database\Eloquent\Builder;
+
+class TestersBlock extends Block
+{
+    /**
+     * @return Builder<Member>
+     */
+    public function query(): Builder
+    {
+        return Member::whereHas('memberships', fn ($q) => $q->isTrying())
+            ->with('memberships', fn ($q) => $q->isTrying());
+    }
+
+    /**
+     * @return array{members: array<int, array{name: string, try_ends_at: string, try_ends_at_human: string}>}
+     */
+    public function data(): array
+    {
+        return [
+            'members' => $this->query()->get()->map(fn ($member) => [
+                'name' => $member->fullname,
+                'try_ends_at' => $member->memberships->first()->from->addWeeks(8)->format('d.m.Y'),
+                'try_ends_at_human' => $member->memberships->first()->from->addWeeks(8)->diffForHumans(),
+            ])->toArray(),
+        ];
+    }
+
+    public function title(): string
+    {
+        return 'Endende Schhnupperzeiten';
+    }
+
+    public function render(): string
+    {
+        return '<div></div>';
+    }
+}
diff --git a/modules/Prevention/EfzPendingBlock.php b/modules/Prevention/EfzPendingBlock.php
new file mode 100644
index 00000000..473ae64d
--- /dev/null
+++ b/modules/Prevention/EfzPendingBlock.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Modules\Prevention;
+
+use Modules\Dashboard\Block;
+use App\Member\Member;
+use Illuminate\Database\Eloquent\Builder;
+
+class EfzPendingBlock extends Block
+{
+    /**
+     * @return Builder<Member>
+     */
+    public function query(): Builder
+    {
+        return Member::where(function ($query) {
+            return $query->where('efz', '<=', now()->subYears(5)->endOfYear())
+                ->orWhereNull('efz');
+        })
+            ->whereCurrentGroup()
+            ->orderByRaw('lastname, firstname')
+            ->whereHas('memberships', fn ($builder) => $builder->isLeader()->active());
+    }
+
+    /**
+     * @return array{members: array<int, string>}
+     */
+    public function data(): array
+    {
+        return [
+            'members' => $this->query()->get()->map(fn ($member) => $member->fullname)->toArray(),
+        ];
+    }
+
+    public function title(): string
+    {
+        return 'Ausstehende Führungszeugnisse';
+    }
+
+    public function render(): string
+    {
+        return '<div></div>';
+    }
+}
diff --git a/modules/Prevention/PsPendingBlock.php b/modules/Prevention/PsPendingBlock.php
new file mode 100644
index 00000000..e6bef7e7
--- /dev/null
+++ b/modules/Prevention/PsPendingBlock.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Modules\Prevention;
+
+use Modules\Dashboard\Block;
+use Illuminate\Database\Eloquent\Builder;
+
+class PsPendingBlock extends Block
+{
+    /**
+     * @return Builder<Member>
+     */
+    public function query(): Builder
+    {
+        return Member::where(function ($query) {
+            $time = now()->subYears(5)->endOfYear();
+
+            return $query
+                ->orWhere(fn ($query) => $query->whereNull('ps_at')->whereNull('more_ps_at'))
+                ->orWhere(fn ($query) => $query->whereNull('ps_at')->where('more_ps_at', '<=', $time))
+                ->orWhere(fn ($query) => $query->where('ps_at', '<=', $time)->whereNull('more_ps_at'))
+                ->orWhere(fn ($query) => $query->where('ps_at', '>=', $time)->where('more_ps_at', '<=', $time));
+        })
+            ->whereCurrentGroup()
+            ->orderByRaw('lastname, firstname')
+            ->whereHas('memberships', fn ($builder) => $builder->isLeader()->active());
+    }
+
+    /**
+     * @return array{members: array{fullname: string}}
+     */
+    public function data(): array
+    {
+        return [
+            'members' => $this->query()->get()->map(fn ($member) => [
+                'fullname' => $member->fullname,
+            ])->toArray(),
+        ];
+    }
+
+    public function title(): string
+    {
+        return 'Ausstehende Präventionsschulungen';
+    }
+
+    public function render(): string
+    {
+        return '<div></div>';
+    }
+}
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php
new file mode 100644
index 00000000..846ac352
--- /dev/null
+++ b/resources/views/components/layouts/app.blade.php
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html class="h-full" lang="de">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
+
+        <title>{{ $title ?? 'Page Title' }}</title>
+        <meta name="socketport" content="{{env('SOCKET_PORT')}}" />
+        <meta name="adrema_base_url" content="/">
+        @if(auth()->id())
+        <meta name="meilisearch_key" content="{{config('scout.meilisearch.key')}}" />
+        @endif
+        @vite('resources/livewire-js/app.js')
+    </head>
+    <body class="min-h-full flex flex-col">
+        <livewire:page.sidebar />
+        {{ $slot }}
+        <page-search-modal v-if="searchVisible" @close="searchVisible = false"></page-search-modal>
+    </body>
+</html>