Compare commits

...

4 Commits

Author SHA1 Message Date
philipp lang 653d59fc18 --wip-- [skip ci] 2024-09-23 02:03:15 +02:00
philipp lang 528b716705 Install Livewire Package 2024-09-23 02:02:03 +02:00
philipp lang 096224fe98 Lint
continuous-integration/drone/push Build is passing Details
2024-09-23 02:01:35 +02:00
philipp lang 646ce647da Move Dashboard Route to DashboardServiceProvider 2024-09-23 01:53:46 +02:00
21 changed files with 796 additions and 5 deletions

View File

@ -2,7 +2,9 @@
namespace App\Dashboard;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider;
use App\Dashboard\Actions\IndexAction as DashboardIndexAction;
class DashboardServiceProvider extends ServiceProvider
{
@ -23,5 +25,8 @@ class DashboardServiceProvider extends ServiceProvider
*/
public function boot()
{
app(Router::class)->middleware(['web', 'auth:web'])->group(function ($router) {
$router->get('/', DashboardIndexAction::class)->name('home');
});
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Blade;
class LivewireServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
Blade::componentNamespace('App\\View\\Ui', 'ui');
}
/**
* Bootstrap services.
*/
public function boot(): void
{
//
}
}

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 @class([
'bg-gray-800 group-[.is-popup]:bg-zinc-700' => !$second,
'bg-gray-700 group-[.is-popup]:bg-zinc-600' => $second,
'p-3 rounded-lg flex flex-col' => true
])>
<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;
}
}

View File

@ -61,6 +61,7 @@
"laravel/ui": "^4.0",
"league/csv": "^9.9",
"league/flysystem-webdav": "dev-master as 3.28.0",
"livewire/livewire": "^3.5",
"lorisleiva/laravel-actions": "^2.4",
"meilisearch/meilisearch-php": "^1.6",
"monicahq/laravel-sabre": "^1.6",
@ -104,6 +105,7 @@
"autoload": {
"psr-4": {
"App\\": "app/",
"Modules\\": "modules/",
"Plugins\\": "plugins/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
@ -111,6 +113,7 @@
},
"autoload-dev": {
"psr-4": {
"Modules\\Dashboard\\Tests\\": "modules/dashboard/tests/",
"Tests\\": "tests/",
"Zoomyboy\\LaravelNami\\Tests\\": "packages/laravel-nami/tests/"
}

78
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "10f0b0d2a8dee4a8dad4821de9935f85",
"content-hash": "67ae3a3987355f098ee6f850c6f8e846",
"packages": [
{
"name": "amphp/amp",
@ -5153,6 +5153,82 @@
],
"time": "2024-03-23T07:42:40+00:00"
},
{
"name": "livewire/livewire",
"version": "v3.5.8",
"source": {
"type": "git",
"url": "https://github.com/livewire/livewire.git",
"reference": "ce1ce71b39a3492b98f7d2f2a4583f1b163fe6ae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/ce1ce71b39a3492b98f7d2f2a4583f1b163fe6ae",
"reference": "ce1ce71b39a3492b98f7d2f2a4583f1b163fe6ae",
"shasum": ""
},
"require": {
"illuminate/database": "^10.0|^11.0",
"illuminate/routing": "^10.0|^11.0",
"illuminate/support": "^10.0|^11.0",
"illuminate/validation": "^10.0|^11.0",
"laravel/prompts": "^0.1.24",
"league/mime-type-detection": "^1.9",
"php": "^8.1",
"symfony/console": "^6.0|^7.0",
"symfony/http-kernel": "^6.2|^7.0"
},
"require-dev": {
"calebporzio/sushi": "^2.1",
"laravel/framework": "^10.15.0|^11.0",
"mockery/mockery": "^1.3.1",
"orchestra/testbench": "^8.21.0|^9.0",
"orchestra/testbench-dusk": "^8.24|^9.1",
"phpunit/phpunit": "^10.4",
"psy/psysh": "^0.11.22|^0.12"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Livewire\\LivewireServiceProvider"
],
"aliases": {
"Livewire": "Livewire\\Livewire"
}
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Livewire\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "A front-end framework for Laravel.",
"support": {
"issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v3.5.8"
},
"funding": [
{
"url": "https://github.com/livewire",
"type": "github"
}
],
"time": "2024-09-20T19:41:19+00:00"
},
{
"name": "lorisleiva/laravel-actions",
"version": "v2.8.4",

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,
@ -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,
],
/*

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,33 @@
<?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'
<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()}}" :component="$block" :second="true">
<livewire:dynamic-component :is="$block"></livewire:dynamic-component>
</x-ui::box>
@endforeach
</div>
HTML;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Modules\Dashboard;
use Modules\Invoice\MemberPaymentBlock;
use Modules\Member\AgeGroupCountBlock;
use Modules\Member\TestersBlock;
use Modules\Prevention\EfzPendingBlock;
use Modules\Prevention\PsPendingBlock;
class DashboardFactory
{
/**
* @var array<int, class-string<Block>>
*/
private array $blocks = [
AgeGroupCountBlock::class,
MemberPaymentBlock::class,
TestersBlock::class,
EfzPendingBlock::class,
PsPendingBlock::class,
];
/**
* @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;
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,52 @@
<?php
namespace Modules\Dashboard\Events;
use Modules\Dashboard\Block;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DashboardShowing
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @var array<int, Block>
*/
public array $blocks = [];
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
public function purge(): void
{
$this->blocks = [];
}
/**
* @param class-string<Block> $block
*/
public function push(string $block): void
{
$this->blocks[] = $block;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -0,0 +1,48 @@
<?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')
->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'
<div>
Example Content
</div>
HTML;
}
}

View File

@ -0,0 +1,42 @@
<?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 component(): string
{
return 'member-payment';
}
public function title(): string
{
return 'Ausstehende Mitgliedsbeiträge';
}
public function render(): string
{
return '<div></div>';
}
}

View File

@ -0,0 +1,63 @@
<?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 component(): string
{
return 'age-group-count';
}
public function title(): string
{
return 'Gruppierungs-Verteilung';
}
public function render(): string
{
return '<div></div>';
}
}

View File

@ -0,0 +1,48 @@
<?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 component(): string
{
return 'testers';
}
public function title(): string
{
return 'Endende Schhnupperzeiten';
}
public function render(): string
{
return '<div></div>';
}
}

View File

@ -0,0 +1,49 @@
<?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 component(): string
{
return 'efz-pending';
}
public function title(): string
{
return 'Ausstehende Führungszeugnisse';
}
public function render(): string
{
return '<div></div>';
}
}

View File

@ -0,0 +1,55 @@
<?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 component(): string
{
return 'ps-pending';
}
public function title(): string
{
return 'Ausstehende Präventionsschulungen';
}
public function render(): string
{
return '<div></div>';
}
}

View File

@ -2,7 +2,7 @@
<page-layout>
<div class="gap-6 md:grid-cols-2 xl:grid-cols-4 grid p-6">
<v-block v-for="(block, index) in blocks" :key="index" :title="block.title">
<component :data="block.data" :is="block.component"></component>
<component :is="block.component" :data="block.data"></component>
</v-block>
</div>
</page-layout>

View File

@ -0,0 +1,3 @@
<html>
{{ $slot }}
</html>

View File

@ -17,7 +17,6 @@ use App\Course\Actions\CourseIndexAction;
use App\Course\Actions\CourseStoreAction;
use App\Invoice\Actions\InvoiceStoreAction;
use App\Course\Actions\CourseUpdateAction;
use App\Dashboard\Actions\IndexAction as DashboardIndexAction;
use App\Efz\ShowEfzDocumentAction;
use App\Fileshare\Actions\FileshareApiIndexAction;
use App\Fileshare\Actions\FileshareStoreAction;
@ -84,7 +83,6 @@ Route::group(['namespace' => 'App\\Http\\Controllers'], function (): void {
});
Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/', DashboardIndexAction::class)->name('home');
Route::post('/nami/login-check', NamiLoginCheckAction::class)->name('nami.login-check');
Route::post('/nami/get-search-layer', NamiGetSearchLayerAction::class)->name('nami.get-search-layer');
Route::post('/nami/search', NamiSearchAction::class)->name('nami.search');