Certificate manager built on Laravel 13, Livewire 4, and Flux. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
199 lines
9.1 KiB
PHP
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>
|