complete story 6.2 with qa tests

This commit is contained in:
Naser Mansour
2025-12-27 19:44:23 +02:00
parent 54e9b0905d
commit 9c9bef0b25
8 changed files with 921 additions and 0 deletions
@@ -11,11 +11,19 @@ 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');
@@ -28,9 +36,60 @@ new class extends Component
'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 () => [
@@ -256,4 +315,294 @@ new class extends Component
</div>
</div>
</div>
{{-- Analytics Charts Section --}}
<div class="mt-8">
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<flux:heading size="lg">{{ __('admin_metrics.analytics_charts') }}</flux:heading>
{{-- Date Range Selector --}}
<div class="flex flex-wrap items-center gap-2">
<flux:button
wire:click="$set('chartPeriod', '6m')"
:variant="$chartPeriod === '6m' ? 'primary' : 'ghost'"
size="sm"
>
{{ __('admin_metrics.last_6_months') }}
</flux:button>
<flux:button
wire:click="$set('chartPeriod', '12m')"
:variant="$chartPeriod === '12m' ? 'primary' : 'ghost'"
size="sm"
>
{{ __('admin_metrics.last_12_months') }}
</flux:button>
{{-- Custom Range --}}
<div class="flex items-center gap-2">
<flux:input
type="month"
wire:model="customStartMonth"
class="w-36"
size="sm"
/>
<span class="text-zinc-500">-</span>
<flux:input
type="month"
wire:model="customEndMonth"
class="w-36"
size="sm"
/>
<flux:button
wire:click="setCustomRange"
:variant="$chartPeriod === 'custom' ? 'primary' : 'ghost'"
size="sm"
>
{{ __('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="trendsChart($wire.chartData)"
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="breakdownChart($wire.chartData.consultationBreakdown)"
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="noShowChart($wire.chartData)"
class="h-64"
>
<canvas x-ref="canvas"></canvas>
</div>
@endif
</div>
</div>
</div>
@script
<script>
// Ensure Chart.js is available
if (typeof Chart === 'undefined') {
console.error('Chart.js is not loaded');
}
Alpine.data('trendsChart', (data) => ({
chart: null,
init() {
if (typeof Chart === 'undefined') return;
this.chart = new Chart(this.$refs.canvas, {
type: 'line',
data: {
labels: data.labels,
datasets: [
{
label: @js(__('admin_metrics.new_clients')),
data: data.newClients,
borderColor: '#D4AF37',
backgroundColor: 'rgba(212, 175, 55, 0.1)',
tension: 0.3,
fill: false,
},
{
label: @js(__('admin_metrics.consultations')),
data: 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();
}
}
}));
Alpine.data('breakdownChart', (data) => ({
chart: null,
init() {
if (typeof Chart === 'undefined') return;
const total = data.free + data.paid;
const freePercent = total > 0 ? Math.round((data.free / total) * 100) : 0;
const paidPercent = total > 0 ? Math.round((data.paid / total) * 100) : 0;
this.chart = new Chart(this.$refs.canvas, {
type: 'doughnut',
data: {
labels: [
@js(__('admin_metrics.free')) + ` (${data.free})`,
@js(__('admin_metrics.paid')) + ` (${data.paid})`
],
datasets: [{
data: [data.free, 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();
}
}
}));
Alpine.data('noShowChart', (data) => ({
chart: null,
init() {
if (typeof Chart === 'undefined') return;
this.chart = new Chart(this.$refs.canvas, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: @js(__('admin_metrics.noshow_rate_percent')),
data: 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();
}
}
}));
</script>
@endscript
</div>