From 5b116a54c4dbd7c2035df13da2f98b76eb179984 Mon Sep 17 00:00:00 2001
From: philipp lang <philipp@aweos.de>
Date: Sun, 13 Oct 2024 21:00:47 +0200
Subject: [PATCH] Add Dashboard

---
 app/Efz/EfzPendingBlock.php                   | 44 --------------
 app/Invoice/MemberPaymentBlock.php            | 37 ------------
 app/Member/PsPendingBlock.php                 | 50 ----------------
 app/Membership/TestersBlock.php               | 43 --------------
 .../Providers}/BaseServiceProvider.php        | 11 +---
 .../Components => app/View}/Page/Header.php   |  2 +-
 app/View/Page/Layout.php                      | 57 +++++++++++++++++++
 .../View}/Page/MenuEntry.php                  |  2 +-
 .../Components => app/View}/Page/Sidebar.php  | 20 +++++--
 .../Base/Components => app/View}/Ui/Box.php   |  2 +-
 .../Components => app/View}/Ui/Sprite.php     |  2 +-
 config/app.php                                |  2 +-
 modules/Base/Components/Page/Layout.php       | 54 ------------------
 modules/Invoice/MemberPaymentBlock.php        | 28 +++++----
 modules/Member/AgeGroupCountBlock.php         | 44 ++++++++++----
 modules/Member/TestersBlock.php               | 39 +++++++------
 modules/Prevention/EfzPendingBlock.php        | 33 ++++++-----
 modules/Prevention/PsPendingBlock.php         | 36 ++++++------
 .../js/views/dashboard/AgeGroupCount.vue      | 29 ----------
 resources/js/views/dashboard/EfzPending.vue   | 31 ----------
 .../js/views/dashboard/MemberPayment.vue      | 26 ---------
 resources/js/views/dashboard/PsPending.vue    | 31 ----------
 resources/js/views/dashboard/Testers.vue      | 33 -----------
 resources/livewire-js/app.js                  |  1 +
 tailwind.config.js                            | 12 ++--
 .../Feature/Base}/PageLayoutTest.php          |  4 +-
 .../Invoice/MemberPaymentBlockTest.php        | 12 ++--
 tests/Feature/Member/PsPendingBlockTest.php   | 30 +++++-----
 .../Membership/AgeGroupCountBlockTest.php     | 17 +++---
 .../Membership/EfzPendingBlockTest.php        | 16 +++---
 tests/Feature/Membership/TestersBlockTest.php | 23 +++-----
 vite.config.js                                |  2 +-
 32 files changed, 237 insertions(+), 536 deletions(-)
 delete mode 100644 app/Efz/EfzPendingBlock.php
 delete mode 100644 app/Invoice/MemberPaymentBlock.php
 delete mode 100644 app/Member/PsPendingBlock.php
 delete mode 100644 app/Membership/TestersBlock.php
 rename {modules/Base => app/Providers}/BaseServiceProvider.php (80%)
 rename {modules/Base/Components => app/View}/Page/Header.php (96%)
 create mode 100644 app/View/Page/Layout.php
 rename {modules/Base/Components => app/View}/Page/MenuEntry.php (94%)
 rename {modules/Base/Components => app/View}/Page/Sidebar.php (72%)
 rename {modules/Base/Components => app/View}/Ui/Box.php (96%)
 rename {modules/Base/Components => app/View}/Ui/Sprite.php (90%)
 delete mode 100644 modules/Base/Components/Page/Layout.php
 delete mode 100644 resources/js/views/dashboard/AgeGroupCount.vue
 delete mode 100644 resources/js/views/dashboard/EfzPending.vue
 delete mode 100644 resources/js/views/dashboard/MemberPayment.vue
 delete mode 100644 resources/js/views/dashboard/PsPending.vue
 delete mode 100644 resources/js/views/dashboard/Testers.vue
 create mode 100644 resources/livewire-js/app.js
 rename {modules/Base/tests => tests/Feature/Base}/PageLayoutTest.php (88%)

diff --git a/app/Efz/EfzPendingBlock.php b/app/Efz/EfzPendingBlock.php
deleted file mode 100644
index 8fa20b2f..00000000
--- a/app/Efz/EfzPendingBlock.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-namespace App\Efz;
-
-use App\Dashboard\Blocks\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 component(): string
-    {
-        return 'efz-pending';
-    }
-
-    public function title(): string
-    {
-        return 'Ausstehende Führungszeugnisse';
-    }
-}
diff --git a/app/Invoice/MemberPaymentBlock.php b/app/Invoice/MemberPaymentBlock.php
deleted file mode 100644
index 71fb4aa3..00000000
--- a/app/Invoice/MemberPaymentBlock.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-namespace App\Invoice;
-
-use App\Dashboard\Blocks\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 component(): string
-    {
-        return 'member-payment';
-    }
-
-    public function title(): string
-    {
-        return 'Ausstehende Mitgliedsbeiträge';
-    }
-}
diff --git a/app/Member/PsPendingBlock.php b/app/Member/PsPendingBlock.php
deleted file mode 100644
index 5cc59c14..00000000
--- a/app/Member/PsPendingBlock.php
+++ /dev/null
@@ -1,50 +0,0 @@
-<?php
-
-namespace App\Member;
-
-use App\Dashboard\Blocks\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 component(): string
-    {
-        return 'ps-pending';
-    }
-
-    public function title(): string
-    {
-        return 'Ausstehende Präventionsschulungen';
-    }
-}
diff --git a/app/Membership/TestersBlock.php b/app/Membership/TestersBlock.php
deleted file mode 100644
index 49cf7f25..00000000
--- a/app/Membership/TestersBlock.php
+++ /dev/null
@@ -1,43 +0,0 @@
-<?php
-
-namespace App\Membership;
-
-use App\Dashboard\Blocks\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 component(): string
-    {
-        return 'testers';
-    }
-
-    public function title(): string
-    {
-        return 'Endende Schhnupperzeiten';
-    }
-}
diff --git a/modules/Base/BaseServiceProvider.php b/app/Providers/BaseServiceProvider.php
similarity index 80%
rename from modules/Base/BaseServiceProvider.php
rename to app/Providers/BaseServiceProvider.php
index 0cb597eb..ce9ec2c7 100644
--- a/modules/Base/BaseServiceProvider.php
+++ b/app/Providers/BaseServiceProvider.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Modules\Base;
+namespace App\Providers;
 
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\Blade;
@@ -21,9 +21,8 @@ class BaseServiceProvider extends ServiceProvider
      */
     public function register(): void
     {
-        Blade::componentNamespace('Modules\\Base\\Components\\Ui', 'ui');
-        Blade::componentNamespace('Modules\\Base\\Components\\Page', 'page');
-
+        Blade::componentNamespace('App\\View\\Ui', 'ui');
+        Blade::componentNamespace('App\\View\\Page', 'page');
 
         app(DashboardFactory::class)->register(AgeGroupCountBlock::class);
         app(DashboardFactory::class)->register(MemberPaymentBlock::class);
@@ -31,8 +30,6 @@ class BaseServiceProvider 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;
@@ -45,7 +42,5 @@ class BaseServiceProvider extends ServiceProvider
      */
     public function boot(): void
     {
-        Livewire::component('pagesidebar', Sidebar::class);
-        //
     }
 }
diff --git a/modules/Base/Components/Page/Header.php b/app/View/Page/Header.php
similarity index 96%
rename from modules/Base/Components/Page/Header.php
rename to app/View/Page/Header.php
index 960223ad..40fb1924 100644
--- a/modules/Base/Components/Page/Header.php
+++ b/app/View/Page/Header.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Modules\Base\Components\Page;
+namespace App\View\Page;
 
 use Illuminate\View\Component;
 
diff --git a/app/View/Page/Layout.php b/app/View/Page/Layout.php
new file mode 100644
index 00000000..1a4851a6
--- /dev/null
+++ b/app/View/Page/Layout.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace App\View\Page;
+
+use Illuminate\View\Component;
+
+class Layout extends Component
+{
+
+    public function __construct(public string $pageClass = '')
+    {
+    }
+
+    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 flex flex-col">
+                <div class="grow bg-gray-900 flex flex-col duration-300 navbar:ml-60">
+                    <x-page::header title="{{ session()->get('title') }}">
+                        <x-slot:beforeTitle>
+                            <a href="#" class="mr-2 lg:hidden" wire:click.prevent="dispatch('toggle-sidebar')">
+                                <x-ui::sprite src="menu" class="text-gray-100 w-5 h-5"></x-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="grow flex flex-col {{$pageClass}}">
+                        {{ $slot }}
+                    </div>
+                </div>
+                <livewire:page.sidebar :mobile="true" />
+            </div>
+        HTML;
+    }
+}
diff --git a/modules/Base/Components/Page/MenuEntry.php b/app/View/Page/MenuEntry.php
similarity index 94%
rename from modules/Base/Components/Page/MenuEntry.php
rename to app/View/Page/MenuEntry.php
index 85ea81df..260fc395 100644
--- a/modules/Base/Components/Page/MenuEntry.php
+++ b/app/View/Page/MenuEntry.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Modules\Base\Components\Page;
+namespace App\View\Page;
 
 use Illuminate\View\Component;
 
diff --git a/modules/Base/Components/Page/Sidebar.php b/app/View/Page/Sidebar.php
similarity index 72%
rename from modules/Base/Components/Page/Sidebar.php
rename to app/View/Page/Sidebar.php
index deab75e4..e44cf987 100644
--- a/modules/Base/Components/Page/Sidebar.php
+++ b/app/View/Page/Sidebar.php
@@ -1,19 +1,25 @@
 <?php
 
-namespace Modules\Base\Components\Page;
+namespace App\View\Page;
 
 use Livewire\Component;
 
 class Sidebar extends Component
 {
 
-    public $isShifted = false;
+    public $mobile = false;
 
     public function render()
     {
         return <<<'HTML'
         <div
-            class="fixed z-40 bg-gray-800 p-6 w-56 top-0 h-screen border-r border-gray-600 border-solid flex flex-col justify-between transition-all {{ $isShifted ? '-left-[14rem]' : 'left-0' }}"
+            class="fixed z-40 bg-gray-800 p-6 w-60 top-0 h-screen border-r border-gray-600 border-solid flex flex-col justify-between duration-300
+                @if (!$mobile) left-[-16rem] navbar:left-0 @endif"
+            @if($mobile)
+                x-data="{ visible: false }"
+                x-on:toggle-sidebar.window="visible = true"
+                :class="{'left-[-16rem]' : !visible, 'left-0': visible}"
+            @endif
         >
             <div class="grid gap-2">
                 <x-page::menu-entry href="/" menu="dashboard" icon="loss">Dashboard</x-page::menu-entry>
@@ -27,16 +33,18 @@ class Sidebar extends Component
                 <x-page::menu-entry href="/maildispatcher" menu="maildispatcher" icon="at">Mail-Verteiler</x-page::menu-entry>
             </div>
             <div class="grid gap-2">
-                <a href="#" class="flex w-full px-3 py-2 rounded-xl text-gray-300 bg-gray-700" @click.prevent="searchVisible = true">
+                <a href="#" class="flex w-full px-3 py-2 rounded-xl text-gray-300 bg-gray-700" @click.prevent="dispatch('show-search')">
                     <x-ui::sprite class="text-white w-6 h-6 mr-4" src="search"></x-ui::sprite>
                     <div class="">Suchen</div>
                 </a>
                 <x-page::menu-entry href="/setting" menu="setting" icon="setting">Einstellungen</x-page::menu-entry>
                 <x-page::menu-entry href="/logout" menu="" icon="logout">Abmelden</x-page::menu-entry>
             </div>
-            <a v-if="menuStore.hideable" href="#" class="absolute right-0 top-0 mr-2 mt-2" @click.prevent="menuStore.hide()">
-                <ui-sprite src="close" class="w-5 h-5 text-gray-300"></ui-sprite>
+            @if($mobile)
+            <a href="#" class="absolute right-0 top-0 mr-2 mt-2" @click.prevent="visible = false">
+                <x-ui::sprite src="close" class="w-5 h-5 text-gray-300"></x-ui::sprite>
             </a>
+            @endif
         </div>
         HTML;
     }
diff --git a/modules/Base/Components/Ui/Box.php b/app/View/Ui/Box.php
similarity index 96%
rename from modules/Base/Components/Ui/Box.php
rename to app/View/Ui/Box.php
index 747a2701..03e51808 100644
--- a/modules/Base/Components/Ui/Box.php
+++ b/app/View/Ui/Box.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Modules\Base\Components\Ui;
+namespace App\View\Ui;
 
 use Illuminate\View\Component;
 
diff --git a/modules/Base/Components/Ui/Sprite.php b/app/View/Ui/Sprite.php
similarity index 90%
rename from modules/Base/Components/Ui/Sprite.php
rename to app/View/Ui/Sprite.php
index 41cd9687..679eaf18 100644
--- a/modules/Base/Components/Ui/Sprite.php
+++ b/app/View/Ui/Sprite.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Modules\Base\Components\Ui;
+namespace App\View\Ui;
 
 use Illuminate\View\Component;
 
diff --git a/config/app.php b/config/app.php
index f567ace3..6330d6d8 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,
-        Modules\Base\BaseServiceProvider::class,
+        App\Providers\BaseServiceProvider::class,
     ],
 
     /*
diff --git a/modules/Base/Components/Page/Layout.php b/modules/Base/Components/Page/Layout.php
deleted file mode 100644
index d0cb95d8..00000000
--- a/modules/Base/Components/Page/Layout.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?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/modules/Invoice/MemberPaymentBlock.php b/modules/Invoice/MemberPaymentBlock.php
index ecd0c7db..0d435ed4 100644
--- a/modules/Invoice/MemberPaymentBlock.php
+++ b/modules/Invoice/MemberPaymentBlock.php
@@ -8,21 +8,20 @@ use App\Member\Member;
 
 class MemberPaymentBlock extends Block
 {
-    /**
-     * @return array<string, string|int>
-     */
-    public function data(): array
+
+    public string $amount = '';
+    public int $members = 0;
+    public int $totalMembers = 0;
+
+    public function mount(): void
     {
         $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, ',', '.') . ' €',
-        ];
+        $this->amount = number_format((int) $amount->price / 100, 2, ',', '.') . ' €';
+        $this->members = Member::whereHasPendingPayment()->count();
+        $this->totalMembers = Member::count();
     }
 
     public function title(): string
@@ -32,6 +31,13 @@ class MemberPaymentBlock extends Block
 
     public function render(): string
     {
-        return '<div></div>';
+        return <<<'HTML'
+            <div>
+                <div class="text-gray-100">
+                    <span class="text-xl mr-1 font-semibold">{{$amount}}</span>
+                    <span class="text-sm">von {{$members}} / {{$totalMembers}} Mitgliedern</span>
+                </div>
+            </div>
+        HTML;
     }
 }
diff --git a/modules/Member/AgeGroupCountBlock.php b/modules/Member/AgeGroupCountBlock.php
index 48d88d79..20ed9eb1 100644
--- a/modules/Member/AgeGroupCountBlock.php
+++ b/modules/Member/AgeGroupCountBlock.php
@@ -8,10 +8,13 @@ use Illuminate\Database\Eloquent\Builder;
 
 class AgeGroupCountBlock extends Block
 {
+
+    public $groups;
+
     /**
      * @return Builder<Membership>
      */
-    public function memberQuery(): Builder
+    protected function memberQuery(): Builder
     {
         return Membership::select('subactivities.slug', 'subactivities.name')
             ->selectRaw('COUNT(member_id) AS count')
@@ -27,7 +30,7 @@ class AgeGroupCountBlock extends Block
     /**
      * @return Builder<Membership>
      */
-    public function leaderQuery(): Builder
+    protected function leaderQuery(): Builder
     {
         return Membership::selectRaw('"leiter" AS slug, "Leiter" AS name, COUNT(member_id) AS count')
             ->join('activities', 'memberships.activity_id', 'activities.id')
@@ -36,13 +39,11 @@ class AgeGroupCountBlock extends Block
             ->isLeader();
     }
 
-    protected function data(): array
+    public function mount(): void
     {
-        return [
-            'groups' => [
-                ...$this->memberQuery()->get()->toArray(),
-                ...$this->leaderQuery()->get()->toArray(),
-            ],
+        $this->groups = [
+            ...$this->memberQuery()->get(),
+            ...$this->leaderQuery()->get(),
         ];
     }
 
@@ -51,11 +52,30 @@ class AgeGroupCountBlock extends Block
         return 'Gruppierungs-Verteilung';
     }
 
+    public function groupColor(string $slug): string
+    {
+        return data_get([
+            'biber' => 'text-biber',
+            'woelfling' => 'text-woelfling',
+            'jungpfadfinder' => 'text-jungpfadfinder',
+            'pfadfinder' => 'text-pfadfinder',
+            'rover' => 'text-rover',
+            'leiter' => 'text-leiter',
+        ], $slug);
+    }
+
     public function render(): string
     {
-        return '<div>
-
-            lalala
-            </div>';
+        return <<<'HTML'
+            <div>
+                @foreach($groups as $group)
+                <div class="flex mt-2 items-center leading-none text-gray-100">
+                    <x-ui::sprite class="w-4 h-4 mr-2 {{ $this->groupColor($group->slug) }}" src="lilie"></x-ui::sprite>
+                    <span class="grow">{{$group->name}}</span>
+                    <span>{{$group->count}}</span>
+                </div>
+                @endforeach
+            </div>
+        HTML;
     }
 }
diff --git a/modules/Member/TestersBlock.php b/modules/Member/TestersBlock.php
index e8fbd71e..0e74a6f4 100644
--- a/modules/Member/TestersBlock.php
+++ b/modules/Member/TestersBlock.php
@@ -5,30 +5,23 @@ namespace Modules\Member;
 use Modules\Dashboard\Block;
 use App\Member\Member;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
 
 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());
-    }
+
+    private $months = 8;
 
     /**
-     * @return array{members: array<int, array{name: string, try_ends_at: string, try_ends_at_human: string}>}
+     * @var Collection<Member>
      */
-    public function data(): array
+    public Collection $members;
+
+    public function mount(): void
     {
-        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(),
-        ];
+        $this->members = Member::whereHas('memberships', fn ($q) => $q->isTrying())
+            ->with('memberships', fn ($q) => $q->isTrying())
+            ->get();
     }
 
     public function title(): string
@@ -38,6 +31,16 @@ class TestersBlock extends Block
 
     public function render(): string
     {
-        return '<div></div>';
+        return <<<'HTML'
+            <div>
+                @foreach($members as $member)
+                <div class="flex mt-2 items-center leading-none text-gray-100">
+                    <span class="grow">{{ $member->fullname }}</span>
+                    <span class="mr-2 text-sm tex-gray-600">{{ $member->memberships->first()->from->addWeeks($this->months)->format('d.m.Y') }}</span>
+                    <span class="text-xs tex-gray-600">{{ $member->memberships->first()->from->addWeeks($this->months)->diffForHumans() }}</span>
+                </div>
+                @endforeach
+            </div>
+        HTML;
     }
 }
diff --git a/modules/Prevention/EfzPendingBlock.php b/modules/Prevention/EfzPendingBlock.php
index 473ae64d..38c2e6cc 100644
--- a/modules/Prevention/EfzPendingBlock.php
+++ b/modules/Prevention/EfzPendingBlock.php
@@ -5,31 +5,26 @@ namespace Modules\Prevention;
 use Modules\Dashboard\Block;
 use App\Member\Member;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
 
 class EfzPendingBlock extends Block
 {
+
     /**
-     * @return Builder<Member>
+     * @var Collection<Member>
      */
-    public function query(): Builder
+    public Collection $members;
+
+    public function mount(): void
     {
-        return Member::where(function ($query) {
+        $this->members = 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(),
-        ];
+            ->whereHas('memberships', fn ($builder) => $builder->isLeader()->active())
+            ->get();
     }
 
     public function title(): string
@@ -39,6 +34,14 @@ class EfzPendingBlock extends Block
 
     public function render(): string
     {
-        return '<div></div>';
+        return <<<'HTML'
+            <div>
+                @foreach($members as $member)
+                <div class="flex mt-2 items-center leading-none text-gray-100">
+                    <span class="grow">{{$member->fullname}}</span>
+                </div>
+                @endforeach
+            </div>
+        HTML;
     }
 }
diff --git a/modules/Prevention/PsPendingBlock.php b/modules/Prevention/PsPendingBlock.php
index e6bef7e7..c4825149 100644
--- a/modules/Prevention/PsPendingBlock.php
+++ b/modules/Prevention/PsPendingBlock.php
@@ -2,17 +2,22 @@
 
 namespace Modules\Prevention;
 
+use App\Member\Member;
 use Modules\Dashboard\Block;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
 
 class PsPendingBlock extends Block
 {
+
     /**
-     * @return Builder<Member>
+     * @var Collection<Member>
      */
-    public function query(): Builder
+    public Collection $members;
+
+    public function mount(): void
     {
-        return Member::where(function ($query) {
+        $this->members = Member::where(function ($query) {
             $time = now()->subYears(5)->endOfYear();
 
             return $query
@@ -23,19 +28,8 @@ class PsPendingBlock extends Block
         })
             ->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(),
-        ];
+            ->whereHas('memberships', fn ($builder) => $builder->isLeader()->active())
+            ->get();
     }
 
     public function title(): string
@@ -45,6 +39,14 @@ class PsPendingBlock extends Block
 
     public function render(): string
     {
-        return '<div></div>';
+        return <<<'HTML'
+            <div>
+                @foreach ($members as $member)
+                <div class="flex mt-2 items-center leading-none text-gray-100">
+                    <span class="grow">{{ $member->fullname }}</span>
+                </div>
+                @endforeach
+            </div>
+        HTML;
     }
 }
diff --git a/resources/js/views/dashboard/AgeGroupCount.vue b/resources/js/views/dashboard/AgeGroupCount.vue
deleted file mode 100644
index 0ae396a9..00000000
--- a/resources/js/views/dashboard/AgeGroupCount.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<template>
-    <div>
-        <div v-for="(group, index) in inner.groups" :key="index" class="flex mt-2 items-center leading-none text-gray-100">
-            <ui-sprite class="w-4 h-4 mr-2" src="lilie" :class="`text-${group.slug}`"></ui-sprite>
-            <span v-text="group.name" class="grow"></span>
-            <span v-text="group.count"></span>
-        </div>
-    </div>
-</template>
-
-<script>
-export default {
-    data: function () {
-        return {
-            inner: {
-                groups: [],
-            },
-        };
-    },
-
-    props: {
-        data: {},
-    },
-
-    created() {
-        this.inner = this.data;
-    },
-};
-</script>
diff --git a/resources/js/views/dashboard/EfzPending.vue b/resources/js/views/dashboard/EfzPending.vue
deleted file mode 100644
index d673b322..00000000
--- a/resources/js/views/dashboard/EfzPending.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<template>
-    <div>
-        <div
-            v-for="(member, index) in inner.members"
-            :key="index"
-            class="flex mt-2 items-center leading-none text-gray-100"
-        >
-            <span class="grow" v-text="`${member}`"></span>
-        </div>
-    </div>
-</template>
-
-<script>
-export default {
-    data: function () {
-        return {
-            inner: {
-                members: [],
-            },
-        };
-    },
-
-    props: {
-        data: {},
-    },
-
-    created() {
-        this.inner = this.data;
-    },
-};
-</script>
diff --git a/resources/js/views/dashboard/MemberPayment.vue b/resources/js/views/dashboard/MemberPayment.vue
deleted file mode 100644
index 48bda0c0..00000000
--- a/resources/js/views/dashboard/MemberPayment.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<template>
-    <div>
-        <div class="text-gray-100">
-            <span class="text-xl mr-1 font-semibold" v-text="inner.amount"></span>
-            <span class="text-sm" v-text="`von ${inner.members} / ${inner.total_members} Mitgliedern`"></span>
-        </div>
-    </div>
-</template>
-
-<script>
-export default {
-    data: function () {
-        return {
-            inner: {},
-        };
-    },
-
-    props: {
-        data: {},
-    },
-
-    created() {
-        this.inner = this.data;
-    },
-};
-</script>
diff --git a/resources/js/views/dashboard/PsPending.vue b/resources/js/views/dashboard/PsPending.vue
deleted file mode 100644
index c2832b48..00000000
--- a/resources/js/views/dashboard/PsPending.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<template>
-    <div>
-        <div
-            v-for="(member, index) in inner.members"
-            :key="index"
-            class="flex mt-2 items-center leading-none text-gray-100"
-        >
-            <span class="grow" v-text="`${member.fullname}`"></span>
-        </div>
-    </div>
-</template>
-
-<script>
-export default {
-    data: function () {
-        return {
-            inner: {
-                members: [],
-            },
-        };
-    },
-
-    props: {
-        data: {},
-    },
-
-    created() {
-        this.inner = this.data;
-    },
-};
-</script>
diff --git a/resources/js/views/dashboard/Testers.vue b/resources/js/views/dashboard/Testers.vue
deleted file mode 100644
index 69334c7a..00000000
--- a/resources/js/views/dashboard/Testers.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<template>
-    <div>
-        <div
-            v-for="(member, index) in inner.members"
-            :key="index"
-            class="flex mt-2 items-center leading-none text-gray-100"
-        >
-            <span class="grow" v-text="`${member.name}`"></span>
-            <span class="mr-2 text-sm tex-gray-600" v-text="`${member.try_ends_at}`"></span>
-            <span class="text-xs tex-gray-600" v-text="`${member.try_ends_at_human}`"></span>
-        </div>
-    </div>
-</template>
-
-<script>
-export default {
-    data: function () {
-        return {
-            inner: {
-                members: [],
-            },
-        };
-    },
-
-    props: {
-        data: {},
-    },
-
-    created() {
-        this.inner = this.data;
-    },
-};
-</script>
diff --git a/resources/livewire-js/app.js b/resources/livewire-js/app.js
new file mode 100644
index 00000000..f65cc9bd
--- /dev/null
+++ b/resources/livewire-js/app.js
@@ -0,0 +1 @@
+import '../css/app.css';
diff --git a/tailwind.config.js b/tailwind.config.js
index 36d36a5d..b38b5aca 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,14 +1,7 @@
 const {colors} = require('tailwindcss/defaultTheme');
 
 module.exports = {
-    content: [
-        'resources/js/views/**/*.vue',
-        'resources/js/components/**/*.vue',
-        'resources/js/layouts/**/*.vue',
-        'resources/views/**/*.blade.php',
-        'resources/js/composables/**/*.js',
-        'packages/medialibrary-helper/**/*.vue',
-    ],
+    content: ['app/View/**/*.php', 'resources/views/**/*.blade.php', 'modules/**/*.php'],
     theme: {
         extend: {
             colors: {
@@ -30,6 +23,9 @@ module.exports = {
                     900: 'hsl(181, 94%, 10%)',
                 },
             },
+            screens: {
+                navbar: '1024px',
+            },
         },
     },
 
diff --git a/modules/Base/tests/PageLayoutTest.php b/tests/Feature/Base/PageLayoutTest.php
similarity index 88%
rename from modules/Base/tests/PageLayoutTest.php
rename to tests/Feature/Base/PageLayoutTest.php
index 6f2be204..c8dced7e 100644
--- a/modules/Base/tests/PageLayoutTest.php
+++ b/tests/Feature/Base/PageLayoutTest.php
@@ -1,14 +1,12 @@
 <?php
 
-namespace Modules\Dashboard\Tests;
+namespace Tests\Feature\Base;
 
 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();
diff --git a/tests/Feature/Invoice/MemberPaymentBlockTest.php b/tests/Feature/Invoice/MemberPaymentBlockTest.php
index dbdc6dfd..0808e214 100644
--- a/tests/Feature/Invoice/MemberPaymentBlockTest.php
+++ b/tests/Feature/Invoice/MemberPaymentBlockTest.php
@@ -8,6 +8,8 @@ use App\Invoice\Models\Invoice;
 use App\Invoice\Models\InvoicePosition;
 use App\Member\Member;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Livewire\Livewire;
+use Modules\Invoice\MemberPaymentBlock as InvoiceMemberPaymentBlock;
 use Tests\TestCase;
 
 class MemberPaymentBlockTest extends TestCase
@@ -27,12 +29,8 @@ class MemberPaymentBlockTest extends TestCase
         Invoice::factory()->has(InvoicePosition::factory()->price(600)->for($member), 'positions')->status(InvoiceStatus::NEW)->create();
         Invoice::factory()->has(InvoicePosition::factory()->price(1000)->for($member), 'positions')->status(InvoiceStatus::PAID)->create();
 
-        $data = app(MemberPaymentBlock::class)->render()['data'];
-
-        $this->assertEquals([
-            'amount' => '51,00 €',
-            'members' => 1,
-            'total_members' => 2,
-        ], $data);
+        Livewire::test(InvoiceMemberPaymentBlock::class)
+            ->assertSee('1 / 2')
+            ->assertSee('51,00 €');
     }
 }
diff --git a/tests/Feature/Member/PsPendingBlockTest.php b/tests/Feature/Member/PsPendingBlockTest.php
index aef909d6..07ae4f74 100644
--- a/tests/Feature/Member/PsPendingBlockTest.php
+++ b/tests/Feature/Member/PsPendingBlockTest.php
@@ -5,8 +5,9 @@ namespace Tests\Feature\Member;
 use App\Group;
 use App\Member\Member;
 use App\Member\Membership;
-use App\Member\PsPendingBlock;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Livewire\Livewire;
+use Modules\Prevention\PsPendingBlock;
 use Tests\TestCase;
 
 class PsPendingBlockTest extends TestCase
@@ -47,7 +48,7 @@ class PsPendingBlockTest extends TestCase
             ->defaults()
             ->for($group)
             ->has(Membership::factory()->in('€ LeiterIn', 5, 'Wölfling', 8)->ended())
-            ->create(['firstname' => 'Nora', 'lastname' => 'Doe', 'more_ps_at' => now()->subYears(5)]);
+            ->create(['firstname' => 'Lisa', 'lastname' => 'Doe', 'more_ps_at' => now()->subYears(5)]);
         $invalidPsButValidMorePs = Member::factory()
             ->defaults()
             ->for($group)
@@ -59,15 +60,15 @@ class PsPendingBlockTest extends TestCase
             ->has(Membership::factory()->in('€ Mitglied', 5, 'Wölfling', 8))
             ->create(['firstname' => 'Mae', 'lastname' => 'Doe']);
 
-        $data = app(PsPendingBlock::class)->render()['data'];
-
-        $this->assertEquals([
-            'members' => [
-                ['fullname' => 'Jane Doe'],
-                ['fullname' => 'Mike Doe'],
-                ['fullname' => 'Nora Doe'],
-            ],
-        ], $data);
+        Livewire::test(PsPendingBlock::class)
+            ->assertSee('Jane Doe')
+            ->assertDontSee('Max Doe')
+            ->assertDontSee('Joe Doe')
+            ->assertSee('Mike Doe')
+            ->assertSee('Nora Doe')
+            ->assertDontSee('Lisa Doe')
+            ->assertDontSee('Hey Doe')
+            ->assertDontSee('Mae Doe');
     }
 
     public function testItExcludesForeignGroups(): void
@@ -80,10 +81,9 @@ class PsPendingBlockTest extends TestCase
             ->defaults()
             ->for($otherGroup)
             ->has(Membership::factory()->in('€ LeiterIn', 5, 'Wölfling', 8))
-            ->create();
+            ->create(['lastname' => 'Doe']);
 
-        $data = app(PsPendingBlock::class)->render()['data'];
-
-        $this->assertCount(0, $data['members']);
+        Livewire::test(PsPendingBlock::class)
+            ->assertDontSee('Doe');
     }
 }
diff --git a/tests/Feature/Membership/AgeGroupCountBlockTest.php b/tests/Feature/Membership/AgeGroupCountBlockTest.php
index 45ba040f..834be7fd 100644
--- a/tests/Feature/Membership/AgeGroupCountBlockTest.php
+++ b/tests/Feature/Membership/AgeGroupCountBlockTest.php
@@ -4,8 +4,9 @@ namespace Tests\Feature\Membership;
 
 use App\Member\Member;
 use App\Member\Membership;
-use App\Membership\AgeGroupCountBlock;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Livewire\Livewire;
+use Modules\Member\AgeGroupCountBlock;
 use Tests\TestCase;
 
 class AgeGroupCountBlockTest extends TestCase
@@ -33,14 +34,10 @@ class AgeGroupCountBlockTest extends TestCase
             ->defaults()
             ->create();
 
-        $data = app(AgeGroupCountBlock::class)->render()['data'];
-
-        $this->assertEquals([
-            'groups' => [
-                ['slug' => 'biber', 'name' => 'Biber', 'count' => 3],
-                ['slug' => 'woelfling', 'name' => 'Wölfling', 'count' => 4],
-                ['slug' => 'leiter', 'name' => 'Leiter', 'count' => 4],
-            ],
-        ], $data);
+        Livewire::test(AgeGroupCountBlock::class)
+            ->assertSee('Biber')
+            ->assertSee('Wölfling')
+            ->assertSee('Leiter')
+            ->assertSeeInOrder([3, 4, 4]);
     }
 }
diff --git a/tests/Feature/Membership/EfzPendingBlockTest.php b/tests/Feature/Membership/EfzPendingBlockTest.php
index 86c34116..8892ba62 100644
--- a/tests/Feature/Membership/EfzPendingBlockTest.php
+++ b/tests/Feature/Membership/EfzPendingBlockTest.php
@@ -7,6 +7,8 @@ use App\Group;
 use App\Member\Member;
 use App\Member\Membership;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Livewire\Livewire;
+use Modules\Prevention\EfzPendingBlock as PreventionEfzPendingBlock;
 use Tests\TestCase;
 
 class EfzPendingBlockTest extends TestCase
@@ -54,11 +56,10 @@ class EfzPendingBlockTest extends TestCase
             ->for($group)
             ->create(['firstname' => 'Doe', 'lastname' => 'Muster', 'efz' => now()->subYears(5)]);
 
-        $data = app(EfzPendingBlock::class)->render()['data'];
-
-        $this->assertEquals([
-            'members' => ['Joe Muster', 'Mae Muster', 'Moa Muster'],
-        ], $data);
+        Livewire::test(PreventionEfzPendingBlock::class)
+            ->assertSee('Joe Muster')
+            ->assertSee('Mae Muster')
+            ->assertSee('Moa Muster');
     }
 
     public function testItExcludesForeignGroups(): void
@@ -73,8 +74,7 @@ class EfzPendingBlockTest extends TestCase
             ->for($otherGroup)
             ->create(['firstname' => 'Joe', 'lastname' => 'Muster', 'efz' => now()->subYears(5)->endOfYear()]);
 
-        $data = app(EfzPendingBlock::class)->render()['data'];
-
-        $this->assertCount(0, $data['members']);
+        Livewire::test(PreventionEfzPendingBlock::class)
+            ->assertDontSee('Joe');
     }
 }
diff --git a/tests/Feature/Membership/TestersBlockTest.php b/tests/Feature/Membership/TestersBlockTest.php
index 6ec66e1b..a1363dcd 100644
--- a/tests/Feature/Membership/TestersBlockTest.php
+++ b/tests/Feature/Membership/TestersBlockTest.php
@@ -4,8 +4,9 @@ namespace Tests\Feature\Membership;
 
 use App\Member\Member;
 use App\Member\Membership;
-use App\Membership\TestersBlock;
 use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Livewire\Livewire;
+use Modules\Member\TestersBlock;
 use Tests\TestCase;
 
 class TestersBlockTest extends TestCase
@@ -20,21 +21,15 @@ class TestersBlockTest extends TestCase
             ->defaults()
             ->has(Membership::factory()->in('Schnuppermitgliedschaft', 7, 'Wölfling', 8)->state(['from' => now()->subMonths(10)]))
             ->create(['firstname' => 'Max', 'lastname' => 'Muster']);
-        $inactiveMember = Member::factory()
+        Member::factory()
             ->defaults()
             ->has(Membership::factory()->ended()->in('Schnuppermitgliedschaft', 7, 'Wölfling', 8)->state(['from' => now()->subMonths(10)]))
-            ->create(['firstname' => 'Max', 'lastname' => 'Muster']);
+            ->create(['firstname' => 'Jane', 'lastname' => 'Muster']);
 
-        $data = app(TestersBlock::class)->render()['data'];
-
-        $this->assertEquals([
-            'members' => [
-                [
-                    'name' => 'Max Muster',
-                    'try_ends_at' => now()->subMonths(10)->addWeeks(8)->format('d.m.Y'),
-                    'try_ends_at_human' => now()->subMonths(10)->addWeeks(8)->diffForHumans(),
-                ],
-            ],
-        ], $data);
+        Livewire::test(TestersBlock::class)
+            ->assertSee('Max Muster')
+            ->assertSee(now()->subMonths(10)->addWeeks(8)->format('d.m.Y'))
+            ->assertSee(now()->subMonths(10)->addWeeks(8)->diffForHumans())
+            ->assertDontSee('Jane');
     }
 }
diff --git a/vite.config.js b/vite.config.js
index 17dc260c..b77ca7e3 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -5,7 +5,7 @@ import path from 'path';
 
 export default defineConfig({
     plugins: [
-        laravel(['resources/js/app.js', 'resources/livewire-js/app.js']),
+        laravel(['resources/livewire-js/app.js']),
         vue({
             template: {
                 transformAssetUrls: {