Certified/resources/views/pages/⚡dashboard.blade.php
Joël van de Wouw ac340d125c
Some checks failed
linter / quality (push) Failing after 13s
tests / ci (8.3) (push) Failing after 2s
tests / ci (8.4) (push) Failing after 2s
tests / ci (8.5) (push) Failing after 3s
Initial commit
Certificate manager built on Laravel 13, Livewire 4, and Flux.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:44:58 +02:00

199 lines
9.1 KiB
PHP

<?php
use App\Enums\ExpiryStatus;
use App\Models\Category;
use App\Models\Certificate;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Dashboard')] class extends Component {
private function months(): int
{
return Auth::user()->reminder_months_before;
}
/**
* @return array{total: int, valid: int, expiringSoon: int, expired: int}
*/
#[Computed]
public function stats(): array
{
$threshold = today()->addMonths($this->months());
$total = Certificate::count();
$expired = Certificate::whereDate('expires_at', '<', today())->count();
$expiringSoon = Certificate::whereDate('expires_at', '>=', today())
->whereDate('expires_at', '<=', $threshold)
->count();
return [
'total' => $total,
'valid' => max(0, $total - $expired - $expiringSoon),
'expiringSoon' => $expiringSoon,
'expired' => $expired,
];
}
/**
* Certificates that are expired or expiring within the user's threshold,
* soonest first — the actionable items.
*
* @return Collection<int, Certificate>
*/
#[Computed]
public function attention(): Collection
{
return Certificate::with('category')
->whereDate('expires_at', '<=', today()->addMonths($this->months()))
->orderBy('expires_at')
->take(6)
->get();
}
/**
* @return Collection<int, Certificate>
*/
#[Computed]
public function recent(): Collection
{
return Certificate::with('category')->latest()->take(5)->get();
}
/**
* @return Collection<int, Category>
*/
#[Computed]
public function categories(): Collection
{
return Category::withCount('certificates')
->orderByDesc('certificates_count')
->orderBy('name')
->get();
}
}; ?>
<section class="flex w-full flex-col gap-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<flux:heading size="xl">{{ __('Welcome back, :name', ['name' => auth()->user()->name]) }}</flux:heading>
<flux:text class="mt-1">{{ __('Here is the status of your certificates.') }}</flux:text>
</div>
<flux:button :href="route('certificates.create')" wire:navigate icon="plus" variant="primary">
{{ __('Add certificate') }}
</flux:button>
</div>
@if ($this->stats['total'] === 0)
<flux:card class="flex flex-col items-center gap-3 py-16 text-center">
<flux:icon.shield-check class="size-10 text-zinc-400" />
<flux:heading size="lg">{{ __('No certificates yet') }}</flux:heading>
<flux:text class="max-w-sm">
{{ __('Upload your first certificate to start tracking issue dates, expiry, and reminders.') }}
</flux:text>
<flux:button :href="route('certificates.create')" wire:navigate icon="plus" variant="primary" class="mt-2">
{{ __('Add your first certificate') }}
</flux:button>
</flux:card>
@else
{{-- Stat tiles --}}
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
@php
$tiles = [
['label' => __('Total'), 'value' => $this->stats['total'], 'icon' => 'rectangle-stack', 'class' => 'text-zinc-800 dark:text-white'],
['label' => __('Valid'), 'value' => $this->stats['valid'], 'icon' => 'check-badge', 'class' => 'text-green-600 dark:text-green-400'],
['label' => __('Expiring soon'), 'value' => $this->stats['expiringSoon'], 'icon' => 'clock', 'class' => 'text-amber-600 dark:text-amber-400'],
['label' => __('Expired'), 'value' => $this->stats['expired'], 'icon' => 'exclamation-triangle', 'class' => 'text-red-600 dark:text-red-400'],
];
@endphp
@foreach ($tiles as $tile)
<flux:card class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<flux:text class="text-sm">{{ $tile['label'] }}</flux:text>
<flux:icon :name="$tile['icon']" class="size-5 text-zinc-400" />
</div>
<flux:heading size="xl" @class(['text-3xl font-semibold', $tile['class']])>{{ $tile['value'] }}</flux:heading>
</flux:card>
@endforeach
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
{{-- Needs attention --}}
<flux:card class="lg:col-span-2">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ __('Needs attention') }}</flux:heading>
<flux:link :href="route('certificates.index')" wire:navigate variant="subtle">{{ __('View all') }}</flux:link>
</div>
@if ($this->attention->isEmpty())
<div class="flex flex-col items-center gap-2 py-8 text-center">
<flux:icon.check-badge class="size-8 text-green-500" />
<flux:text>{{ __('Nothing needs attention. All your certificates are up to date.') }}</flux:text>
</div>
@else
<div class="divide-y divide-zinc-100 dark:divide-zinc-700">
@foreach ($this->attention as $certificate)
@php($status = $certificate->expiryStatus($this->stats['total'] ? auth()->user()->reminder_months_before : 1))
<a
href="{{ route('certificates.edit', $certificate) }}"
wire:navigate
class="flex items-center justify-between gap-4 py-3 transition hover:opacity-70"
>
<div class="min-w-0">
<flux:text class="truncate font-medium text-zinc-800 dark:text-white">{{ $certificate->title }}</flux:text>
<flux:text class="text-sm">{{ $certificate->category?->name ?? __('Uncategorized') }}</flux:text>
</div>
<div class="flex flex-shrink-0 items-center gap-3 text-right">
<div>
<flux:text class="text-sm">{{ $certificate->expires_at->format('M j, Y') }}</flux:text>
<flux:text class="text-xs text-zinc-400">{{ $certificate->expires_at->diffForHumans() }}</flux:text>
</div>
<flux:badge size="sm" :color="$status->color()">{{ $status->label() }}</flux:badge>
</div>
</a>
@endforeach
</div>
@endif
</flux:card>
<div class="flex flex-col gap-6">
{{-- By category --}}
<flux:card>
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ __('By category') }}</flux:heading>
<flux:link :href="route('categories.index')" wire:navigate variant="subtle">{{ __('Manage') }}</flux:link>
</div>
@if ($this->categories->isEmpty())
<flux:text class="text-sm">{{ __('No categories yet.') }}</flux:text>
@else
<div class="flex flex-col gap-2">
@foreach ($this->categories as $category)
<div class="flex items-center justify-between">
<flux:text class="truncate">{{ $category->name }}</flux:text>
<flux:badge size="sm" color="zinc">{{ $category->certificates_count }}</flux:badge>
</div>
@endforeach
</div>
@endif
</flux:card>
{{-- Recently added --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Recently added') }}</flux:heading>
<div class="flex flex-col gap-3">
@foreach ($this->recent as $certificate)
<a href="{{ route('certificates.edit', $certificate) }}" wire:navigate class="flex items-center justify-between gap-3 transition hover:opacity-70">
<flux:text class="truncate">{{ $certificate->title }}</flux:text>
<flux:text class="flex-shrink-0 text-xs text-zinc-400">{{ $certificate->created_at?->diffForHumans() }}</flux:text>
</a>
@endforeach
</div>
</flux:card>
</div>
</div>
@endif
</section>