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>