diff --git a/app/Providers/LivewireServiceProvider.php b/app/Providers/LivewireServiceProvider.php
new file mode 100644
index 00000000..c4691d96
--- /dev/null
+++ b/app/Providers/LivewireServiceProvider.php
@@ -0,0 +1,25 @@
+ !$second,
+ 'bg-gray-700 group-[.is-popup]:bg-zinc-600' => $second,
+ 'p-3 rounded-lg flex flex-col' => true
+ ])>
+
+ @if($title)
+
+ @endif
+ {{$inTitle}}
+
+
+ {{ $slot }}
+
+
+ HTML;
+ }
+}
diff --git a/config/app.php b/config/app.php
index 5f2a4bbd..3e3f43c1 100644
--- a/config/app.php
+++ b/config/app.php
@@ -168,7 +168,6 @@ return [
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
- // App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\RouteServiceProvider::class,
@@ -178,6 +177,8 @@ return [
App\Setting\SettingServiceProvider::class,
App\Dashboard\DashboardServiceProvider::class,
App\Providers\PluginServiceProvider::class,
+ // Modules\Dashboard\DashboardServiceProvider::class,
+ // App\Providers\LivewireServiceProvider::class,
],
/*
diff --git a/config/livewire.php b/config/livewire.php
new file mode 100644
index 00000000..e764241e
--- /dev/null
+++ b/config/livewire.php
@@ -0,0 +1,160 @@
+ 'App\\View',
+
+ /*
+ |---------------------------------------------------------------------------
+ | View Path
+ |---------------------------------------------------------------------------
+ |
+ | This value is used to specify where Livewire component Blade templates are
+ | stored when running file creation commands like `artisan make:livewire`.
+ | It is also used if you choose to omit a component's render() method.
+ |
+ */
+
+ 'view_path' => resource_path('views/livewire'),
+
+ /*
+ |---------------------------------------------------------------------------
+ | Layout
+ |---------------------------------------------------------------------------
+ | The view that will be used as the layout when rendering a single component
+ | as an entire page via `Route::get('/post/create', CreatePost::class);`.
+ | In this case, the view returned by CreatePost will render into $slot.
+ |
+ */
+
+ 'layout' => 'components.layouts.app',
+
+ /*
+ |---------------------------------------------------------------------------
+ | Lazy Loading Placeholder
+ |---------------------------------------------------------------------------
+ | Livewire allows you to lazy load components that would otherwise slow down
+ | the initial page load. Every component can have a custom placeholder or
+ | you can define the default placeholder view for all components below.
+ |
+ */
+
+ 'lazy_placeholder' => null,
+
+ /*
+ |---------------------------------------------------------------------------
+ | Temporary File Uploads
+ |---------------------------------------------------------------------------
+ |
+ | Livewire handles file uploads by storing uploads in a temporary directory
+ | before the file is stored permanently. All file uploads are directed to
+ | a global endpoint for temporary storage. You may configure this below:
+ |
+ */
+
+ 'temporary_file_upload' => [
+ 'disk' => null, // Example: 'local', 's3' | Default: 'default'
+ 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
+ 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
+ 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
+ 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
+ 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
+ 'mov', 'avi', 'wmv', 'mp3', 'm4a',
+ 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
+ ],
+ 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
+ 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
+ ],
+
+ /*
+ |---------------------------------------------------------------------------
+ | Render On Redirect
+ |---------------------------------------------------------------------------
+ |
+ | This value determines if Livewire will run a component's `render()` method
+ | after a redirect has been triggered using something like `redirect(...)`
+ | Setting this to true will render the view once more before redirecting
+ |
+ */
+
+ 'render_on_redirect' => false,
+
+ /*
+ |---------------------------------------------------------------------------
+ | Eloquent Model Binding
+ |---------------------------------------------------------------------------
+ |
+ | Previous versions of Livewire supported binding directly to eloquent model
+ | properties using wire:model by default. However, this behavior has been
+ | deemed too "magical" and has therefore been put under a feature flag.
+ |
+ */
+
+ 'legacy_model_binding' => false,
+
+ /*
+ |---------------------------------------------------------------------------
+ | Auto-inject Frontend Assets
+ |---------------------------------------------------------------------------
+ |
+ | By default, Livewire automatically injects its JavaScript and CSS into the
+ | and of pages containing Livewire components. By disabling
+ | this behavior, you need to use @livewireStyles and @livewireScripts.
+ |
+ */
+
+ 'inject_assets' => true,
+
+ /*
+ |---------------------------------------------------------------------------
+ | Navigate (SPA mode)
+ |---------------------------------------------------------------------------
+ |
+ | By adding `wire:navigate` to links in your Livewire application, Livewire
+ | will prevent the default link handling and instead request those pages
+ | via AJAX, creating an SPA-like effect. Configure this behavior here.
+ |
+ */
+
+ 'navigate' => [
+ 'show_progress_bar' => true,
+ 'progress_bar_color' => '#2299dd',
+ ],
+
+ /*
+ |---------------------------------------------------------------------------
+ | HTML Morph Markers
+ |---------------------------------------------------------------------------
+ |
+ | Livewire intelligently "morphs" existing HTML into the newly rendered HTML
+ | after each update. To make this process more reliable, Livewire injects
+ | "markers" into the rendered Blade surrounding @if, @class & @foreach.
+ |
+ */
+
+ 'inject_morph_markers' => true,
+
+ /*
+ |---------------------------------------------------------------------------
+ | Pagination Theme
+ |---------------------------------------------------------------------------
+ |
+ | When enabling Livewire's pagination feature by using the `WithPagination`
+ | trait, Livewire will use Tailwind templates to render pagination views
+ | on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
+ |
+ */
+
+ 'pagination_theme' => 'tailwind',
+];
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 @@
+put('menu', 'dashboard');
+ session()->put('title', 'Dashboard');
+
+ $this->blocks = $factory->load();
+ }
+
+ public function render(): string
+ {
+ return <<<'HTML'
+
+ @foreach($this->blocks as $block)
+
+
+
+ @endforeach
+
+ HTML;
+ }
+}
diff --git a/modules/Dashboard/DashboardFactory.php b/modules/Dashboard/DashboardFactory.php
new file mode 100644
index 00000000..20ba4784
--- /dev/null
+++ b/modules/Dashboard/DashboardFactory.php
@@ -0,0 +1,48 @@
+>
+ */
+ private array $blocks = [
+ AgeGroupCountBlock::class,
+ MemberPaymentBlock::class,
+ TestersBlock::class,
+ EfzPendingBlock::class,
+ PsPendingBlock::class,
+ ];
+
+ /**
+ * @return array
+ */
+ public function load(): array
+ {
+ return collect($this->blocks)->map(fn ($block) => app($block))->toArray();
+ }
+
+ /**
+ * @param class-string $block
+ */
+ public function register(string $block): self
+ {
+ $this->blocks[] = $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 @@
+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/Events/DashboardShowing.php b/modules/Dashboard/Events/DashboardShowing.php
new file mode 100644
index 00000000..892d22f3
--- /dev/null
+++ b/modules/Dashboard/Events/DashboardShowing.php
@@ -0,0 +1,52 @@
+
+ */
+ public array $blocks = [];
+
+ /**
+ * Create a new event instance.
+ */
+ public function __construct()
+ {
+ //
+ }
+
+ public function purge(): void
+ {
+ $this->blocks = [];
+ }
+
+ /**
+ * @param class-string $block
+ */
+ public function push(string $block): void
+ {
+ $this->blocks[] = $block;
+ }
+
+ /**
+ * Get the channels the event should broadcast on.
+ *
+ * @return array
+ */
+ public function broadcastOn(): array
+ {
+ return [
+ new PrivateChannel('channel-name'),
+ ];
+ }
+}
diff --git a/modules/Dashboard/tests/DashboardComponentTest.php b/modules/Dashboard/tests/DashboardComponentTest.php
new file mode 100644
index 00000000..7fd7256d
--- /dev/null
+++ b/modules/Dashboard/tests/DashboardComponentTest.php
@@ -0,0 +1,48 @@
+login()->loginNami();
+
+ app(DashboardFactory::class)->purge();
+ app(DashboardFactory::class)->register(ExampleBlock::class);
+
+ Livewire::test(DashboardComponent::class)
+ ->assertSee('ExampleTitle')
+ ->assertSee('Example Content');
+});
+
+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'
+
+ Example Content
+
+ HTML;
+ }
+}
diff --git a/modules/Invoice/MemberPaymentBlock.php b/modules/Invoice/MemberPaymentBlock.php
new file mode 100644
index 00000000..76271371
--- /dev/null
+++ b/modules/Invoice/MemberPaymentBlock.php
@@ -0,0 +1,42 @@
+
+ */
+ 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';
+ }
+
+ public function render(): string
+ {
+ return '';
+ }
+}
diff --git a/modules/Member/AgeGroupCountBlock.php b/modules/Member/AgeGroupCountBlock.php
new file mode 100644
index 00000000..f09b6e34
--- /dev/null
+++ b/modules/Member/AgeGroupCountBlock.php
@@ -0,0 +1,63 @@
+
+ */
+ 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
+ */
+ 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 component(): string
+ {
+ return 'age-group-count';
+ }
+
+ public function title(): string
+ {
+ return 'Gruppierungs-Verteilung';
+ }
+
+ public function render(): string
+ {
+ return '';
+ }
+}
diff --git a/modules/Member/TestersBlock.php b/modules/Member/TestersBlock.php
new file mode 100644
index 00000000..a4f516ae
--- /dev/null
+++ b/modules/Member/TestersBlock.php
@@ -0,0 +1,48 @@
+
+ */
+ public function query(): Builder
+ {
+ return Member::whereHas('memberships', fn ($q) => $q->isTrying())
+ ->with('memberships', fn ($q) => $q->isTrying());
+ }
+
+ /**
+ * @return array{members: array}
+ */
+ 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';
+ }
+
+ public function render(): string
+ {
+ return '';
+ }
+}
diff --git a/modules/Prevention/EfzPendingBlock.php b/modules/Prevention/EfzPendingBlock.php
new file mode 100644
index 00000000..01ef42a6
--- /dev/null
+++ b/modules/Prevention/EfzPendingBlock.php
@@ -0,0 +1,49 @@
+
+ */
+ 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}
+ */
+ 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';
+ }
+
+ public function render(): string
+ {
+ return '';
+ }
+}
diff --git a/modules/Prevention/PsPendingBlock.php b/modules/Prevention/PsPendingBlock.php
new file mode 100644
index 00000000..2198bab9
--- /dev/null
+++ b/modules/Prevention/PsPendingBlock.php
@@ -0,0 +1,55 @@
+
+ */
+ 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';
+ }
+
+ public function render(): string
+ {
+ return '';
+ }
+}
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php
new file mode 100644
index 00000000..4769b81e
--- /dev/null
+++ b/resources/views/components/layouts/app.blade.php
@@ -0,0 +1,3 @@
+
+ {{ $slot }}
+