libra/resources/views/livewire/admin/dashboard.blade.php

620 lines
31 KiB
PHP

<?php
use App\Enums\ConsultationStatus;
use App\Enums\ConsultationType;
use App\Enums\PostStatus;
use App\Enums\TimelineStatus;
use App\Enums\UserStatus;
use App\Enums\UserType;
use App\Models\Consultation;
use App\Models\Post;
use App\Models\Timeline;
use App\Models\TimelineUpdate;
use App\Models\User;
use App\Services\AnalyticsService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Livewire\Volt\Component;
new class extends Component
{
public string $chartPeriod = '6m';
public ?string $customStartMonth = null;
public ?string $customEndMonth = null;
public function getTitle(): string
{
return __('admin_metrics.title');
}
public function with(): array
{
return [
'userMetrics' => $this->getUserMetrics(),
'bookingMetrics' => $this->getBookingMetrics(),
'timelineMetrics' => $this->getTimelineMetrics(),
'postMetrics' => $this->getPostMetrics(),
'chartData' => $this->getChartData(),
];
}
public function updatedChartPeriod(): void
{
// Reset custom range when switching to preset
if ($this->chartPeriod !== 'custom') {
$this->customStartMonth = null;
$this->customEndMonth = null;
}
}
public function setCustomRange(): void
{
if ($this->customStartMonth && $this->customEndMonth) {
$this->chartPeriod = 'custom';
}
}
private function getChartData(): array
{
$service = app(AnalyticsService::class);
$months = match ($this->chartPeriod) {
'6m' => 6,
'12m' => 12,
'custom' => $this->getCustomMonthCount(),
default => 6,
};
$startDate = $this->chartPeriod === 'custom' && $this->customStartMonth
? Carbon::parse($this->customStartMonth)->startOfMonth()
: now()->subMonths($months - 1)->startOfMonth();
return [
'labels' => $service->getMonthLabels($startDate, $months),
'newClients' => $service->getMonthlyNewClients($startDate, $months),
'consultations' => $service->getMonthlyConsultations($startDate, $months),
'consultationBreakdown' => $service->getConsultationTypeBreakdown($startDate, $months),
'noShowRates' => $service->getMonthlyNoShowRates($startDate, $months),
];
}
private function getCustomMonthCount(): int
{
if (! $this->customStartMonth || ! $this->customEndMonth) {
return 6;
}
return Carbon::parse($this->customStartMonth)
->diffInMonths(Carbon::parse($this->customEndMonth)) + 1;
}
private function getUserMetrics(): array
{
return Cache::remember('admin.metrics.users', 300, fn () => [
'total_active' => User::query()
->where('status', UserStatus::Active)
->whereIn('user_type', [UserType::Individual, UserType::Company])
->count(),
'individual' => User::query()
->where('user_type', UserType::Individual)
->where('status', UserStatus::Active)
->count(),
'company' => User::query()
->where('user_type', UserType::Company)
->where('status', UserStatus::Active)
->count(),
'deactivated' => User::query()
->where('status', UserStatus::Deactivated)
->whereIn('user_type', [UserType::Individual, UserType::Company])
->count(),
'new_this_month' => User::query()
->whereIn('user_type', [UserType::Individual, UserType::Company])
->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count(),
]);
}
private function getBookingMetrics(): array
{
return Cache::remember('admin.metrics.bookings', 300, function () {
$total = Consultation::query()
->whereIn('status', [ConsultationStatus::Completed, ConsultationStatus::NoShow])
->count();
$noShows = Consultation::query()
->where('status', ConsultationStatus::NoShow)
->count();
return [
'pending' => Consultation::query()
->where('status', ConsultationStatus::Pending)
->count(),
'today' => Consultation::query()
->whereDate('booking_date', today())
->where('status', ConsultationStatus::Approved)
->count(),
'this_week' => Consultation::query()
->whereBetween('booking_date', [now()->startOfWeek(), now()->endOfWeek()])
->whereIn('status', [ConsultationStatus::Approved, ConsultationStatus::Pending])
->count(),
'this_month' => Consultation::query()
->whereMonth('booking_date', now()->month)
->whereYear('booking_date', now()->year)
->count(),
'free' => Consultation::query()
->where('consultation_type', ConsultationType::Free)
->count(),
'paid' => Consultation::query()
->where('consultation_type', ConsultationType::Paid)
->count(),
'no_show_rate' => $total > 0 ? round(($noShows / $total) * 100, 1) : 0,
];
});
}
private function getTimelineMetrics(): array
{
return Cache::remember('admin.metrics.timelines', 300, fn () => [
'active' => Timeline::query()
->where('status', TimelineStatus::Active)
->count(),
'archived' => Timeline::query()
->where('status', TimelineStatus::Archived)
->count(),
'updates_this_week' => TimelineUpdate::query()
->where('created_at', '>=', now()->subWeek())
->count(),
]);
}
private function getPostMetrics(): array
{
return Cache::remember('admin.metrics.posts', 300, fn () => [
'total_published' => Post::query()
->where('status', PostStatus::Published)
->count(),
'this_month' => Post::query()
->where('status', PostStatus::Published)
->whereMonth('published_at', now()->month)
->whereYear('published_at', now()->year)
->count(),
]);
}
}; ?>
<div>
<div class="mb-6">
<flux:heading size="xl" class="text-xl sm:text-2xl">{{ __('admin_metrics.title') }}</flux:heading>
<flux:text class="mt-1 text-zinc-500 dark:text-zinc-400">{{ __('admin_metrics.subtitle') }}</flux:text>
</div>
<div class="stats-grid">
{{-- User Metrics Card --}}
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-[#0A1F44] text-white dark:bg-[#D4AF37] dark:text-zinc-900">
<flux:icon name="users" class="h-5 w-5" />
</div>
<flux:heading size="lg">{{ __('admin_metrics.clients') }}</flux:heading>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.total_active') }}</flux:text>
<span class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ $userMetrics['total_active'] }}</span>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.individual') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $userMetrics['individual'] }}</span>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.company') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $userMetrics['company'] }}</span>
</div>
<div class="border-t border-zinc-200 pt-3 dark:border-zinc-700">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.deactivated') }}</flux:text>
<span class="font-medium text-zinc-500 dark:text-zinc-400">{{ $userMetrics['deactivated'] }}</span>
</div>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.new_this_month') }}</flux:text>
<flux:badge color="lime" size="sm">{{ $userMetrics['new_this_month'] }}</flux:badge>
</div>
</div>
</div>
{{-- Booking Metrics Card --}}
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-[#0A1F44] text-white dark:bg-[#D4AF37] dark:text-zinc-900">
<flux:icon name="calendar" class="h-5 w-5" />
</div>
<flux:heading size="lg">{{ __('admin_metrics.consultations') }}</flux:heading>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.pending_requests') }}</flux:text>
<flux:badge color="amber" size="sm">{{ $bookingMetrics['pending'] }}</flux:badge>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.today') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $bookingMetrics['today'] }}</span>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.this_week') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $bookingMetrics['this_week'] }}</span>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.this_month') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $bookingMetrics['this_month'] }}</span>
</div>
<div class="border-t border-zinc-200 pt-3 dark:border-zinc-700">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.free') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $bookingMetrics['free'] }}</span>
</div>
<div class="mt-2 flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.paid') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $bookingMetrics['paid'] }}</span>
</div>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.no_show_rate') }}</flux:text>
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $bookingMetrics['no_show_rate'] }}%</span>
</div>
</div>
</div>
{{-- Timeline Metrics Card --}}
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-[#0A1F44] text-white dark:bg-[#D4AF37] dark:text-zinc-900">
<flux:icon name="clipboard-document-list" class="h-5 w-5" />
</div>
<flux:heading size="lg">{{ __('admin_metrics.timelines') }}</flux:heading>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.active_cases') }}</flux:text>
<span class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ $timelineMetrics['active'] }}</span>
</div>
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.archived') }}</flux:text>
<span class="font-medium text-zinc-500 dark:text-zinc-400">{{ $timelineMetrics['archived'] }}</span>
</div>
<div class="border-t border-zinc-200 pt-3 dark:border-zinc-700">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.updates_this_week') }}</flux:text>
<flux:badge color="lime" size="sm">{{ $timelineMetrics['updates_this_week'] }}</flux:badge>
</div>
</div>
</div>
</div>
{{-- Posts Metrics Card --}}
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-[#0A1F44] text-white dark:bg-[#D4AF37] dark:text-zinc-900">
<flux:icon name="document-text" class="h-5 w-5" />
</div>
<flux:heading size="lg">{{ __('admin_metrics.posts') }}</flux:heading>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.total_published') }}</flux:text>
<span class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ $postMetrics['total_published'] }}</span>
</div>
<div class="border-t border-zinc-200 pt-3 dark:border-zinc-700">
<div class="flex items-center justify-between">
<flux:text class="text-zinc-600 dark:text-zinc-400">{{ __('admin_metrics.published_this_month') }}</flux:text>
<flux:badge color="lime" size="sm">{{ $postMetrics['this_month'] }}</flux:badge>
</div>
</div>
</div>
</div>
</div>
{{-- Analytics Charts Section --}}
<div class="mt-8">
<div class="page-header mb-6">
<flux:heading size="lg">{{ __('admin_metrics.analytics_charts') }}</flux:heading>
{{-- Date Range Selector --}}
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex gap-2">
<flux:button
wire:click="$set('chartPeriod', '6m')"
:variant="$chartPeriod === '6m' ? 'primary' : 'ghost'"
size="sm"
class="flex-1 sm:flex-none"
>
{{ __('admin_metrics.last_6_months') }}
</flux:button>
<flux:button
wire:click="$set('chartPeriod', '12m')"
:variant="$chartPeriod === '12m' ? 'primary' : 'ghost'"
size="sm"
class="flex-1 sm:flex-none"
>
{{ __('admin_metrics.last_12_months') }}
</flux:button>
</div>
{{-- Custom Range --}}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<div class="flex items-center gap-2">
<flux:input
type="month"
wire:model="customStartMonth"
class="w-full sm:w-36"
size="sm"
/>
<span class="text-zinc-500">-</span>
<flux:input
type="month"
wire:model="customEndMonth"
class="w-full sm:w-36"
size="sm"
/>
</div>
<flux:button
wire:click="setCustomRange"
:variant="$chartPeriod === 'custom' ? 'primary' : 'ghost'"
size="sm"
class="w-full sm:w-auto"
>
{{ __('admin_metrics.apply') }}
</flux:button>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
{{-- Monthly Trends Chart --}}
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<flux:heading size="sm" class="mb-4">{{ __('admin_metrics.monthly_trends') }}</flux:heading>
@if (array_sum($chartData['newClients']) === 0 && array_sum($chartData['consultations']) === 0)
<div class="flex h-64 items-center justify-center">
<flux:text class="text-zinc-500 dark:text-zinc-400">{{ __('admin_metrics.no_data_available') }}</flux:text>
</div>
@else
<div
wire:ignore
x-data="{
chart: null,
data: @js($chartData),
init() {
if (typeof Chart === 'undefined' || !this.$refs.canvas) return;
this.chart = new Chart(this.$refs.canvas, {
type: 'line',
data: {
labels: this.data.labels,
datasets: [
{
label: @js(__('admin_metrics.new_clients')),
data: this.data.newClients,
borderColor: '#D4AF37',
backgroundColor: 'rgba(212, 175, 55, 0.1)',
tension: 0.3,
fill: false,
},
{
label: @js(__('admin_metrics.consultations')),
data: this.data.consultations,
borderColor: '#0A1F44',
backgroundColor: 'rgba(10, 31, 68, 0.1)',
tension: 0.3,
fill: false,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: { enabled: true },
legend: {
position: 'bottom',
rtl: document.dir === 'rtl',
}
},
scales: {
y: {
beginAtZero: true,
ticks: { precision: 0 }
}
}
}
});
},
destroy() {
if (this.chart) this.chart.destroy();
}
}"
class="h-64"
>
<canvas x-ref="canvas"></canvas>
</div>
@endif
</div>
{{-- Consultation Breakdown Chart --}}
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
<flux:heading size="sm" class="mb-4">{{ __('admin_metrics.consultation_breakdown') }}</flux:heading>
@if ($chartData['consultationBreakdown']['free'] === 0 && $chartData['consultationBreakdown']['paid'] === 0)
<div class="flex h-64 items-center justify-center">
<flux:text class="text-zinc-500 dark:text-zinc-400">{{ __('admin_metrics.no_data_available') }}</flux:text>
</div>
@else
<div
wire:ignore
x-data="{
chart: null,
data: @js($chartData['consultationBreakdown']),
init() {
if (typeof Chart === 'undefined' || !this.$refs.canvas) return;
const total = this.data.free + this.data.paid;
this.chart = new Chart(this.$refs.canvas, {
type: 'doughnut',
data: {
labels: [
@js(__('admin_metrics.free')) + ` (${this.data.free})`,
@js(__('admin_metrics.paid')) + ` (${this.data.paid})`
],
datasets: [{
data: [this.data.free, this.data.paid],
backgroundColor: ['#D4AF37', '#0A1F44'],
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const value = context.parsed;
const percent = total > 0 ? Math.round((value / total) * 100) : 0;
return `${context.label}: ${percent}%`;
}
}
},
legend: {
position: 'bottom',
rtl: document.dir === 'rtl',
}
}
}
});
},
destroy() {
if (this.chart) this.chart.destroy();
}
}"
class="h-64"
>
<canvas x-ref="canvas"></canvas>
</div>
@endif
</div>
{{-- No-show Rate Chart --}}
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800 lg:col-span-2">
<flux:heading size="sm" class="mb-4">{{ __('admin_metrics.noshow_rate_trend') }}</flux:heading>
@if (array_sum($chartData['noShowRates']) === 0)
<div class="flex h-64 items-center justify-center">
<flux:text class="text-zinc-500 dark:text-zinc-400">{{ __('admin_metrics.no_data_available') }}</flux:text>
</div>
@else
<div
wire:ignore
x-data="{
chart: null,
data: @js($chartData),
init() {
if (typeof Chart === 'undefined' || !this.$refs.canvas) return;
this.chart = new Chart(this.$refs.canvas, {
type: 'line',
data: {
labels: this.data.labels,
datasets: [{
label: @js(__('admin_metrics.noshow_rate_percent')),
data: this.data.noShowRates,
borderColor: '#E74C3C',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
fill: true,
tension: 0.3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
return context.parsed.y + '%';
}
}
},
legend: {
position: 'bottom',
rtl: document.dir === 'rtl',
},
annotation: {
annotations: {
threshold: {
type: 'line',
yMin: 20,
yMax: 20,
borderColor: 'rgba(231, 76, 60, 0.5)',
borderWidth: 2,
borderDash: [5, 5],
label: {
display: true,
content: @js(__('admin_metrics.concerning_threshold')),
position: 'end'
}
}
}
}
},
scales: {
y: {
min: 0,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
}
}
}
});
},
destroy() {
if (this.chart) this.chart.destroy();
}
}"
class="h-64"
>
<canvas x-ref="canvas"></canvas>
</div>
@endif
</div>
</div>
</div>
{{-- Quick Actions Panel Section --}}
<div class="mt-8">
<flux:heading size="lg" class="mb-6">{{ __('widgets.quick_actions') }}</flux:heading>
<div class="widget-grid">
{{-- Quick Actions Panel --}}
<div class="rounded-lg border border-zinc-200 bg-white p-4 sm:p-6 dark:border-zinc-700 dark:bg-zinc-800 lg:col-span-3">
<livewire:admin.widgets.quick-actions />
</div>
{{-- Pending Bookings Widget --}}
<div class="rounded-lg border border-zinc-200 bg-white p-4 sm:p-6 dark:border-zinc-700 dark:bg-zinc-800">
<livewire:admin.widgets.pending-bookings />
</div>
{{-- Today's Schedule Widget --}}
<div class="rounded-lg border border-zinc-200 bg-white p-4 sm:p-6 dark:border-zinc-700 dark:bg-zinc-800">
<livewire:admin.widgets.todays-schedule />
</div>
{{-- Recent Updates Widget --}}
<div class="rounded-lg border border-zinc-200 bg-white p-4 sm:p-6 dark:border-zinc-700 dark:bg-zinc-800">
<livewire:admin.widgets.recent-updates />
</div>
</div>
</div>
</div>