Certified/resources/views/pages/⚡dashboard.blade.php
Joël van de Wouw a3b3d7d029 Polish app branding with shared brand components
Extract the shield mark into reusable brand-icon/brand-mark components
and use them in the app shell, auth layout, and marketing nav/footer.
Align the palette on emerald (badges, selection, dark zinc-950 body),
and add a subtle grid/glow backdrop to the app layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 15:37:54 +02:00

204 lines
10 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 rounded-2xl py-16 text-center">
<span class="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-1.5 text-sm font-medium text-emerald-700 dark:border-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400">
{{ __('Get started') }}
</span>
<x-brand-mark size="lg" class="mt-2" />
<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', 'tile' => 'bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-md shadow-emerald-500/25'],
['label' => __('Valid'), 'value' => $this->stats['valid'], 'icon' => 'check-badge', 'class' => 'text-emerald-600 dark:text-emerald-400', 'tile' => 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'],
['label' => __('Expiring soon'), 'value' => $this->stats['expiringSoon'], 'icon' => 'clock', 'class' => 'text-amber-600 dark:text-amber-400', 'tile' => 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400'],
['label' => __('Expired'), 'value' => $this->stats['expired'], 'icon' => 'exclamation-triangle', 'class' => 'text-red-600 dark:text-red-400', 'tile' => 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'],
];
@endphp
@foreach ($tiles as $tile)
<flux:card class="flex flex-col gap-2 rounded-2xl transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-emerald-500/10 dark:hover:shadow-black/30">
<div class="flex items-center justify-between">
<flux:text class="text-sm">{{ $tile['label'] }}</flux:text>
<span @class(['flex size-9 items-center justify-center rounded-xl', $tile['tile']])>
<flux:icon :name="$tile['icon']" class="size-5" />
</span>
</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="rounded-2xl 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-emerald-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 class="rounded-2xl">
<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 class="rounded-2xl">
<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>