Add dashboard blocks

This commit is contained in:
philipp lang 2022-11-17 02:15:29 +01:00
parent a16c0f469c
commit 5a97574a86
25 changed files with 624 additions and 171 deletions

View File

@ -0,0 +1,43 @@
<?php
namespace App\Efz;
use App\Home\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');
})
->orderByRaw('lastname, firstname')
->whereHas('memberships', fn ($builder) => $builder->isLeader());
}
/**
* @return array{member: 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

@ -0,0 +1,32 @@
<?php
namespace App\Home\Actions;
use App\Home\DashboardFactory;
use Illuminate\Http\Request;
use Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class IndexAction
{
use AsAction;
/**
* @return array<array-key, mixed>
*/
public function handle(): array
{
return [
'blocks' => app(DashboardFactory::class)->render(),
];
}
public function asController(Request $request): Response
{
session()->put('menu', 'dashboard');
session()->put('title', 'Dashboard');
return Inertia::render('home/VIndex', $this->handle());
}
}

24
app/Home/Blocks/Block.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Home\Blocks;
abstract class Block
{
abstract protected function data(): array;
abstract protected function title(): string;
abstract protected function component(): string;
/**
* @return array{data: array<array-key, mixed>, title: string, component: string}
*/
public function render(): array
{
return [
'data' => $this->data(),
'title' => $this->title(),
'component' => $this->component(),
];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Home;
use App\Efz\EfzPendingBlock;
use App\Home\Blocks\Block;
use App\Membership\AgeGroupCountBlock;
use App\Membership\TestersBlock;
use App\Payment\MemberPaymentBlock;
class DashboardFactory
{
/**
* @var array<int, class-string<Block>>
*/
private array $blocks = [
AgeGroupCountBlock::class,
MemberPaymentBlock::class,
TestersBlock::class,
EfzPendingBlock::class,
];
/**
* @return array<array-key, mixed>
*/
public function render(): array
{
return collect($this->blocks)->map(fn ($block): array => app($block)->render())->toArray();
}
/**
* @param class-string<Block> $block
*/
public function register(string $block): self
{
$this->blocks[] = $block;
return $this;
}
public function purge(): self
{
$this->blocks = [];
return $this;
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Home;
use Illuminate\Support\ServiceProvider;
class HomeServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
app()->singleton(DashboardFactory::class, fn () => new DashboardFactory());
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Home\Queries;
use App\Member\Membership;
use Illuminate\Database\Eloquent\Builder;
class GroupQuery
{
/**
* @var Builder<Membership>
*/
private Builder $query;
public function execute(): self
{
$this->query = 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()
->groupBy('subactivities.slug', 'subactivities.name')
->orderBy('subactivity_id');
return $this;
}
/**
* @return array<int, array{slug: string, name: string, count: int}>
*/
public function getResult(): array
{
return $this->query->get()->toArray();
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Views\HomeView;
use Illuminate\Http\Request;
use Inertia\Response;
class HomeController extends Controller
{
public function __invoke(Request $request): Response
{
session()->put('menu', 'dashboard');
session()->put('title', 'Dashboard');
return \Inertia::render('home/VIndex', app(HomeView::class)->index($request));
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Http\Views;
use App\Home\Queries\GroupQuery;
use App\Member\Member;
use App\Payment\Payment;
use Illuminate\Http\Request;
class HomeView
{
public function index(Request $request): array
{
/** @var object{a: string} */
$amount = Payment::whereNeedsPayment()->selectRaw('sum(subscriptions.amount) AS a')->join('subscriptions', 'subscriptions.id', 'payments.subscription_id')->first();
$members = Member::whereHasPendingPayment()->count();
return [
'data' => [
'payments' => [
'users' => $members,
'all_users' => Member::count(),
'amount' => number_format($amount->a / 100, 2, ',', '.').' €',
],
'groups' => app(GroupQuery::class)->execute()->getResult(),
'ending_tries' => MemberTriesResource::collection(Member::endingTries()->get()),
],
];
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Views;
use App\Member\MemberResource;
/**
* @mixin \App\Member\Member
*/
class MemberTriesResource extends MemberResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*
* @return array
*/
public function toArray($request)
{
return array_merge(parent::toArray($request), [
'try_ends_at' => $this->getModel()->try_created_at->addWeeks(8)->format('d.m.Y'),
'try_ends_at_human' => $this->getModel()->try_created_at->addWeeks(8)->diffForHumans(),
]);
}
}

View File

@ -28,10 +28,9 @@ use Zoomyboy\LaravelNami\Api;
use Zoomyboy\LaravelNami\Data\MembershipEntry;
/**
* @property string $subscription_name
* @property int $pending_payment
* @property bool $is_confirmed
* @property \Carbon\Carbon $try_created_at
* @property string $subscription_name
* @property int $pending_payment
* @property bool $is_confirmed
*/
class Member extends Model
{
@ -356,19 +355,6 @@ class Member extends Model
return $q;
}
public function scopeEndingTries(Builder $q): Builder
{
return $q->whereHas('memberships', fn ($q) => $q
->where('created_at', '<=', now()->subWeeks(7))
->trying()
)
->addSelect([
'try_created_at' => Membership::select('created_at')
->whereColumn('memberships.member_id', 'members.id')
->trying(),
]);
}
public static function fromVcard(string $url, string $data): static
{
$settings = app(NamiSettings::class);

View File

@ -0,0 +1,42 @@
<?php
namespace App\Membership;
use App\Home\Blocks\Block;
use App\Member\Membership;
use Illuminate\Database\Eloquent\Builder;
class AgeGroupCountBlock extends Block
{
/**
* @return Builder<Membership>
*/
public function query(): 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()
->groupBy('subactivities.slug', 'subactivities.name')
->orderBy('subactivity_id');
}
protected function data(): array
{
return [
'groups' => $this->query()->get()->toArray(),
];
}
public function component(): string
{
return 'age-group-count';
}
public function title(): string
{
return 'Gruppierungs-Verteilung';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Membership;
use App\Home\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
->where('created_at', '<=', now()->subWeeks(7))
->trying()
)
->with(['memberships' => fn ($query) => $query->trying()]);
}
/**
* @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()->created_at->addWeeks(8)->format('d.m.Y'),
'try_ends_at_human' => $member->memberships->first()->created_at->addWeeks(8)->diffForHumans(),
])->toArray(),
];
}
public function component(): string
{
return 'testers';
}
public function title(): string
{
return 'Endende Schhnupperzeiten';
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Payment;
use App\Home\Blocks\Block;
use App\Member\Member;
class MemberPaymentBlock extends Block
{
/**
* @return array<string, string|int>
*/
public function data(): array
{
$amount = Payment::whereNeedsPayment()->selectRaw('sum(subscriptions.amount) AS nr')->join('subscriptions', 'subscriptions.id', 'payments.subscription_id')->first();
$members = Member::whereHasPendingPayment()->count();
return [
'members' => $members,
'total_members' => Member::count(),
'amount' => number_format($amount->nr / 100, 2, ',', '.').' €',
];
}
public function component(): string
{
return 'member-payment';
}
public function title(): string
{
return 'Ausstehende Mitgliedsbeiträge';
}
}

View File

@ -188,6 +188,7 @@ return [
App\Tex\TexServiceProvider::class,
App\Dav\ServiceProvider::class,
App\Setting\SettingServiceProvider::class,
App\Home\HomeServiceProvider::class,
],
/*

View File

@ -0,0 +1,33 @@
<template>
<div>
<div
v-for="(group, index) in inner.groups"
:key="index"
class="flex mt-2 items-center leading-none text-gray-100"
>
<svg-sprite class="w-4 h-4 mr-2" src="lilie" :class="`text-${group.slug}`"></svg-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

@ -0,0 +1,31 @@
<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

@ -0,0 +1,26 @@
<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

@ -0,0 +1,33 @@
<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>

View File

@ -1,25 +1,16 @@
<template>
<div class="gap-6 grid-cols-4 grid p-6">
<v-block v-for="(block, index) in blocks" :key="index" :title="block.title">
<v-component :data="block.data" :is="block.component"></v-component>
</v-block>
<!--
<v-block title="Ausstehende Mitgliedsbeiträge">
<div class="text-gray-100">
<span class="text-xl mr-1 font-semibold" v-text="data.payments.amount"></span>
<span class="text-sm" v-text="`von ${data.payments.users} / ${data.payments.all_users} Mitgliedern`"></span>
</div>
</v-block>
<v-block title="Gruppierungs-Verteilung">
<div v-for="group, index in data.groups" :key="index" class="flex mt-2 items-center leading-none text-gray-100">
<svg-sprite class="w-4 h-4 mr-2" src="lilie" :class="`text-${group.slug}`"></svg-sprite>
<span v-text="group.name" class="grow"></span>
<span v-text="group.count"></span>
</div>
</v-block>
<v-block title="Endende Schhnupperzeiten">
<div v-for="member, index in data.ending_tries" :key="index" class="flex mt-2 items-center leading-none text-gray-100">
<span class="grow" v-text="`${member.firstname} ${member.lastname}`"></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>
</v-block>
-->
</div>
</template>
@ -27,12 +18,15 @@
export default {
props: {
data: {},
blocks: {}
blocks: {},
},
components: {
'VBlock': () => import('./VBlock')
}
'VBlock': () => import('./VBlock'),
'age-group-count': () => import('./AgeGroupCount.vue'),
'efz-pending': () => import('./EfzPending.vue'),
'testers': () => import('./Testers.vue'),
'member-payment': () => import('./MemberPayment.vue'),
},
};
</script>

View File

@ -3,7 +3,7 @@
use App\Contribution\ContributionController;
use App\Course\Controllers\CourseController;
use App\Efz\ShowEfzDocumentAction;
use App\Http\Controllers\HomeController;
use App\Home\Actions\IndexAction as HomeIndexAction;
use App\Initialize\Actions\InitializeAction;
use App\Initialize\Actions\InitializeFormAction;
use App\Member\Controllers\MemberResyncController;
@ -22,7 +22,7 @@ Route::group(['namespace' => 'App\\Http\\Controllers'], function (): void {
});
Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/', HomeController::class)->name('home');
Route::get('/', HomeIndexAction::class)->name('home');
Route::get('/initialize', InitializeFormAction::class)->name('initialize.form');
Route::post('/initialize', InitializeAction::class)->name('initialize.store');
Route::resource('member', MemberController::class);

View File

@ -2,8 +2,8 @@
namespace Tests\Feature;
use App\Member\Member;
use App\Member\Membership;
use App\Home\Blocks\Block;
use App\Home\DashboardFactory;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
@ -11,34 +11,39 @@ class HomeTest extends TestCase
{
use DatabaseTransactions;
public function testItDisplaysAgeGroups(): void
public function testItDisplaysBlock(): void
{
$this->withoutExceptionHandling();
Member::factory()->count(3)
->has(Membership::factory()->in('€ Mitglied', 1, 'Biber', 2))
->defaults()
->create();
Member::factory()->count(4)
->has(Membership::factory()->in('€ Mitglied', 1, 'Wölfling', 3))
->defaults()
->create();
Member::factory()->has(Membership::factory()->in('€ LeiterIn', 2, 'Wölfling', 3))
->defaults()
->create();
app(DashboardFactory::class)->purge();
app(DashboardFactory::class)->register(ExampleBlock::class);
$this->login()->loginNami();
$response = $this->get('/');
$this->assertInertiaHas([
'slug' => 'biber',
'name' => 'Biber',
'count' => 3,
], $response, 'data.groups.0');
$this->assertInertiaHas([
'slug' => 'woelfling',
'name' => 'Wölfling',
'count' => 4,
], $response, 'data.groups.1');
$this->assertInertiaHas(['class' => 'name'], $response, 'blocks.0.data');
$this->assertInertiaHas('Example', $response, 'blocks.0.title');
$this->assertInertiaHas('exa', $response, 'blocks.0.component');
}
}
class ExampleBlock extends Block
{
public function title(): string
{
return 'Example';
}
/**
* @return array<string, string>
*/
public function data(): array
{
return ['class' => 'name'];
}
public function component(): string
{
return 'exa';
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Tests\Feature\Membership;
use App\Member\Member;
use App\Member\Membership;
use App\Membership\AgeGroupCountBlock;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class AgeGroupCountBlockTest extends TestCase
{
use DatabaseTransactions;
public function testItDisplaysAgeGroups(): void
{
$this->withoutExceptionHandling();
Member::factory()->count(3)
->has(Membership::factory()->in('€ Mitglied', 1, 'Biber', 2))
->defaults()
->create();
Member::factory()->count(4)
->has(Membership::factory()->in('€ Mitglied', 1, 'Wölfling', 3))
->defaults()
->create();
Member::factory()->has(Membership::factory()->in('€ LeiterIn', 2, 'Wölfling', 3))
->defaults()
->create();
$data = app(AgeGroupCountBlock::class)->render();
$this->assertEquals([
'groups' => [
['slug' => 'biber', 'name' => 'Biber', 'count' => 3],
['slug' => 'woelfling', 'name' => 'Wölfling', 'count' => 4],
],
], $data);
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Tests\Feature\Membership;
use App\Efz\EfzPendingBlock;
use App\Member\Member;
use App\Member\Membership;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class EfzPendingBlockTest extends TestCase
{
use DatabaseTransactions;
public function testItDisplaysEfzPending(): void
{
$this->withoutExceptionHandling();
Member::factory()
->has(Membership::factory()->in('€ LeiterIn', 1, 'Biber', 2))
->defaults()
->create(['firstname' => 'Max', 'lastname' => 'Muster', 'efz' => now()->subYear()]);
Member::factory()
->has(Membership::factory()->in('€ Mitglied', 1, 'Biber', 2))
->defaults()
->create(['firstname' => 'Jane', 'lastname' => 'Muster', 'efz' => now()->subYear()]);
Member::factory()
->has(Membership::factory()->in('€ LeiterIn', 1, 'Biber', 2))
->defaults()
->create(['firstname' => 'Mae', 'lastname' => 'Muster', 'efz' => now()->subYears(5)->startOfYear()]);
Member::factory()
->has(Membership::factory()->in('€ LeiterIn', 1, 'Biber', 2))
->defaults()
->create(['firstname' => 'Joe', 'lastname' => 'Muster', 'efz' => now()->subYears(5)->endOfYear()]);
Member::factory()
->has(Membership::factory()->in('€ LeiterIn', 1, 'Biber', 2))
->defaults()
->create(['firstname' => 'Moa', 'lastname' => 'Muster', 'efz' => null]);
Member::factory()
->has(Membership::factory()->in('€ Mitglied', 1, 'Biber', 2))
->defaults()
->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);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Tests\Feature\Membership;
use App\Member\Member;
use App\Member\Membership;
use App\Membership\TestersBlock;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class TestersBlockTest extends TestCase
{
use DatabaseTransactions;
public function testItHasData(): void
{
$this->login()->loginNami();
Member::factory()
->defaults()
->has(Membership::factory()->in('Schnuppermitgliedschaft', 7, 'Wölfling', 8)->state(['created_at' => now()->subMonths(10)]))
->create(['firstname' => 'Max', 'lastname' => 'Muster']);
$data = app(TestersBlock::class)->render();
$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);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\Payment;
use App\Member\Member;
use App\Payment\MemberPaymentBlock;
use App\Payment\Payment;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class TestersBlockTest extends TestCase
{
use DatabaseTransactions;
public function testItHasData(): void
{
$this->login()->loginNami();
Member::factory()
->defaults()
->has(Payment::factory()->notPaid()->subscription('example', 3400))
->create();
Member::factory()
->defaults()
->create();
$data = app(MemberPaymentBlock::class)->render();
$this->assertEquals([
'amount' => '34,00 €',
'members' => 1,
'total_members' => 2,
], $data);
}
}