Compare commits

...

4 Commits

Author SHA1 Message Date
philipp lang 580238d665 Add Dashboard
continuous-integration/drone/push Build is failing Details
2024-10-13 21:00:47 +02:00
philipp lang 6d85a7c37c Add Service Provider 2024-09-24 01:26:08 +02:00
philipp lang f2c30b013b Add livewire components 2024-09-23 23:49:17 +02:00
philipp lang acaccbb106 Add config file 2024-09-23 02:06:25 +02:00
39 changed files with 951 additions and 362 deletions

1
.gitignore vendored
View File

@ -40,3 +40,4 @@ Homestead.json
/public/sprite.svg
/.php-cs-fixer.cache
/groups.sql
/.phpunit.cache/

View File

@ -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';
}
}

View File

@ -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';
}
}

View File

@ -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';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Providers;
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;
use Modules\Member\TestersBlock;
use Modules\Prevention\EfzPendingBlock;
use Modules\Prevention\PsPendingBlock;
class BaseServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
Blade::componentNamespace('App\\View\\Ui', 'ui');
Blade::componentNamespace('App\\View\\Page', 'page');
app(DashboardFactory::class)->register(AgeGroupCountBlock::class);
app(DashboardFactory::class)->register(MemberPaymentBlock::class);
app(DashboardFactory::class)->register(TestersBlock::class);
app(DashboardFactory::class)->register(EfzPendingBlock::class);
app(DashboardFactory::class)->register(PsPendingBlock::class);
ComponentAttributeBag::macro('mergeWhen', function ($condition, $key, $attributes) {
/** @var ComponentAttributeBag */
$self = $this;
return $condition ? $self->merge([$key => $attributes]) : $self;
});
}
/**
* Bootstrap services.
*/
public function boot(): void
{
}
}

34
app/View/Page/Header.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\View\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;
}
}

57
app/View/Page/Layout.php Normal file
View File

@ -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;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\View\Page;
use Illuminate\View\Component;
class MenuEntry extends Component
{
public function __construct(
public string $href,
public string $menu,
public string $icon,
) {
}
public function render()
{
return <<<'HTML'
<a class="flex text-white py-2 px-3 rounded-lg hover:bg-gray-600 {{ $menu === session('menu') ? 'bg-gray-700' : '' }}" href="{{$href}}">
<x-ui::sprite class="text-white w-6 h-6 mr-4" src="{{$icon}}"></x-ui::sprite>
<span class="font-semibold">
{{ $slot }}
</span>
</a>
HTML;
}
}

51
app/View/Page/Sidebar.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace App\View\Page;
use Livewire\Component;
class Sidebar extends Component
{
public $mobile = false;
public function render()
{
return <<<'HTML'
<div
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>
<x-page::menu-entry href="/member" menu="member" icon="user">Mitglieder</x-page::menu-entry>
<x-page::menu-entry v-show="hasModule('bill')" href="/subscription" menu="subscription" icon="money">Beiträge</x-page::menu-entry>
<x-page::menu-entry v-show="hasModule('bill')" href="/invoice" menu="invoice" icon="moneypaper">Rechnungen</x-page::menu-entry>
<x-page::menu-entry href="/contribution" menu="contribution" icon="contribution">Zuschüsse</x-page::menu-entry>
<x-page::menu-entry href="/activity" menu="activity" icon="activity">Tätigkeiten</x-page::menu-entry>
<x-page::menu-entry href="/group" menu="group" icon="group">Gruppierungen</x-page::menu-entry>
<x-page::menu-entry v-if="hasModule('event')" href="/form" menu="form" icon="event">Veranstaltungen</x-page::menu-entry>
<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="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>
@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;
}
}

38
app/View/Ui/Box.php Normal file
View File

@ -0,0 +1,38 @@
<?php
namespace App\View\Ui;
use Illuminate\View\Component;
class Box extends Component
{
public function __construct(
public string $containerClass = '',
public bool $second = false,
public string $title = '',
public string $inTitle = '',
) {
}
public function render()
{
return <<<'HTML'
<section {!! $attributes
->mergeWhen($second, 'class', 'bg-gray-700 group-[.is-popup]:bg-zinc-600')
->mergeWhen(!$second, 'class', 'bg-gray-800 group-[.is-popup]:bg-zinc-700')
->mergeWhen(true, 'class', 'p-3 rounded-lg flex flex-col')
!!}>
<div class="flex items-center">
@if($title)
<div class="col-span-full font-semibold text-gray-300 group-[.is-popup]:text-zinc-300">{{$title}}</div>
@endif
{{$inTitle}}
</div>
<main class="{{ $title ? 'mt-2' : '' }} {{ $containerClass }}">
{{ $slot }}
</main>
</section>
HTML;
}
}

21
app/View/Ui/Sprite.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\View\Ui;
use Illuminate\View\Component;
class Sprite extends Component
{
public function __construct(
public string $src = '',
) {
}
public function render()
{
return <<<'HTML'
<svg {{ $attributes->merge(['class' => 'fill-current']) }}"><use xlink:href="/sprite.svg#{{$src}}" /></svg>
HTML;
}
}

View File

@ -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/"
}

View File

@ -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,
@ -176,8 +175,10 @@ return [
App\Tex\TexServiceProvider::class,
App\Dav\ServiceProvider::class,
App\Setting\SettingServiceProvider::class,
App\Dashboard\DashboardServiceProvider::class,
// App\Dashboard\DashboardServiceProvider::class,
App\Providers\PluginServiceProvider::class,
Modules\Dashboard\DashboardServiceProvider::class,
App\Providers\BaseServiceProvider::class,
],
/*

160
config/livewire.php Normal file
View File

@ -0,0 +1,160 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => '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
| <head> and <body> 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',
];

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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');
});
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Modules\Invoice;
use Modules\Dashboard\Block;
use App\Invoice\Models\InvoicePosition;
use App\Member\Member;
class MemberPaymentBlock extends Block
{
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();
$this->amount = number_format((int) $amount->price / 100, 2, ',', '.') . ' €';
$this->members = Member::whereHasPendingPayment()->count();
$this->totalMembers = Member::count();
}
public function title(): string
{
return 'Ausstehende Mitgliedsbeiträge';
}
public function render(): string
{
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;
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Modules\Member;
use Modules\Dashboard\Block;
use App\Member\Membership;
use Illuminate\Database\Eloquent\Builder;
class AgeGroupCountBlock extends Block
{
public $groups;
/**
* @return Builder<Membership>
*/
protected 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>
*/
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')
->join('subactivities', 'memberships.subactivity_id', 'subactivities.id')
->active()
->isLeader();
}
public function mount(): void
{
$this->groups = [
...$this->memberQuery()->get(),
...$this->leaderQuery()->get(),
];
}
public function title(): string
{
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 <<<'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;
}
}

View File

@ -0,0 +1,46 @@
<?php
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
{
private $months = 8;
/**
* @var Collection<Member>
*/
public Collection $members;
public function mount(): void
{
$this->members = Member::whereHas('memberships', fn ($q) => $q->isTrying())
->with('memberships', fn ($q) => $q->isTrying())
->get();
}
public function title(): string
{
return 'Endende Schhnupperzeiten';
}
public function render(): string
{
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;
}
}

View File

@ -0,0 +1,47 @@
<?php
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
{
/**
* @var Collection<Member>
*/
public Collection $members;
public function mount(): void
{
$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())
->get();
}
public function title(): string
{
return 'Ausstehende Führungszeugnisse';
}
public function render(): string
{
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;
}
}

View File

@ -1,18 +1,23 @@
<?php
namespace App\Member;
namespace Modules\Prevention;
use App\Dashboard\Blocks\Block;
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,28 +28,25 @@ 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(),
];
}
public function component(): string
{
return 'ps-pending';
->whereHas('memberships', fn ($builder) => $builder->isLeader()->active())
->get();
}
public function title(): string
{
return 'Ausstehende Präventionsschulungen';
}
public function render(): string
{
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;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

1
resources/livewire-js/app.js vendored Normal file
View File

@ -0,0 +1 @@
import '../css/app.css';

View File

@ -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>

12
tailwind.config.js vendored
View File

@ -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',
},
},
},

View File

@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Base;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Livewire\Component;
use Livewire\Livewire;
uses(DatabaseTransactions::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;
}
}

View File

@ -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 €');
}
}

View File

@ -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');
}
}

View File

@ -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]);
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

2
vite.config.js vendored
View File

@ -5,7 +5,7 @@ import path from 'path';
export default defineConfig({
plugins: [
laravel(['resources/js/app.js']),
laravel(['resources/livewire-js/app.js']),
vue({
template: {
transformAssetUrls: {