complete story 6.2 with qa tests
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user