reviewed epic 5 & 6 stories
This commit is contained in:
@@ -6,44 +6,563 @@
|
||||
## User Story
|
||||
As an **admin**,
|
||||
I want **visual charts showing trends and historical data**,
|
||||
So that **I can analyze business patterns over time**.
|
||||
So that **I can identify patterns in client acquisition and consultation outcomes to make informed business decisions**.
|
||||
|
||||
## Prerequisites / Dependencies
|
||||
|
||||
This story requires the following to be completed first:
|
||||
|
||||
| Dependency | Required From | What's Needed |
|
||||
|------------|---------------|---------------|
|
||||
| Dashboard Layout | Story 6.1 | Admin dashboard page structure and route |
|
||||
| User Model | Epic 2 | `users` table with `created_at` for tracking new clients |
|
||||
| Consultation Model | Epic 3 | `consultations` table with `consultation_type`, `status`, `scheduled_date` |
|
||||
| Admin Layout | Epic 1 | Admin authenticated layout with navigation |
|
||||
|
||||
**References:**
|
||||
- Epic 6 details: `docs/epics/epic-6-admin-dashboard.md`
|
||||
- PRD Analytics Section: `docs/prd.md` Section 11.1 (Analytics & Reporting)
|
||||
- PRD Dashboard Section: `docs/prd.md` Section 5.7 (Admin Dashboard)
|
||||
- Story 6.1 for dashboard structure: `docs/stories/story-6.1-dashboard-overview-statistics.md`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Charts Required
|
||||
- [ ] Monthly Trends (Line chart): New clients and consultations per month
|
||||
- [ ] Consultation Breakdown (Pie/Donut): Free vs paid ratio
|
||||
- [ ] No-show Rate (Line chart): Monthly no-show trend
|
||||
### Monthly Trends Chart (Line Chart)
|
||||
- [ ] Display new clients per month (from `users.created_at`)
|
||||
- [ ] Display consultations per month (from `consultations.scheduled_date`)
|
||||
- [ ] Two data series on same chart with legend
|
||||
- [ ] X-axis: Month labels (e.g., "Jan 2025", "Feb 2025")
|
||||
- [ ] Y-axis: Count values with appropriate scale
|
||||
|
||||
### Features
|
||||
- [ ] Date range selector (6 months, 12 months, custom)
|
||||
- [ ] Chart tooltips with exact values
|
||||
- [ ] Responsive charts
|
||||
- [ ] Bilingual labels
|
||||
### Consultation Breakdown Chart (Pie/Donut)
|
||||
- [ ] Show free vs paid consultation ratio
|
||||
- [ ] Display percentage labels on segments
|
||||
- [ ] Legend showing "Free" and "Paid" with counts
|
||||
|
||||
## Technical Notes
|
||||
### No-show Rate Chart (Line Chart)
|
||||
- [ ] Monthly no-show percentage trend
|
||||
- [ ] X-axis: Month labels
|
||||
- [ ] Y-axis: Percentage (0-100%)
|
||||
- [ ] Visual threshold line at concerning rate (e.g., 20%)
|
||||
|
||||
Use Chart.js for visualizations. Aggregate data server-side.
|
||||
### Date Range Selector
|
||||
- [ ] Preset options: Last 6 months, Last 12 months
|
||||
- [ ] Custom date range picker (start/end month)
|
||||
- [ ] Charts update when range changes
|
||||
- [ ] Default selection: Last 6 months
|
||||
|
||||
### Chart Features
|
||||
- [ ] Tooltips showing exact values on hover
|
||||
- [ ] Responsive sizing (charts resize with viewport)
|
||||
- [ ] Bilingual labels (Arabic/English based on locale)
|
||||
- [ ] Loading state while fetching data
|
||||
- [ ] Empty state when no data available
|
||||
|
||||
### Design
|
||||
- [ ] Charts section below statistics cards from Story 6.1
|
||||
- [ ] Card-based layout for each chart
|
||||
- [ ] Navy blue and gold color scheme per PRD Section 7.1
|
||||
- [ ] Responsive grid (1 column mobile, 2 columns tablet+)
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `resources/views/livewire/admin/dashboard.blade.php` | Add charts section to existing dashboard |
|
||||
| `app/Services/AnalyticsService.php` | Data aggregation service for chart data |
|
||||
|
||||
### Integration with Story 6.1 Dashboard
|
||||
|
||||
The charts will be added as a new section in the existing dashboard component from Story 6.1. Add below the statistics cards section.
|
||||
|
||||
### Component Updates (Volt Class-Based)
|
||||
|
||||
Add to the existing dashboard component:
|
||||
|
||||
```php
|
||||
public function getChartData(string $period = '6m'): array
|
||||
{
|
||||
$months = $period === '6m' ? 6 : 12;
|
||||
<?php
|
||||
|
||||
return [
|
||||
'labels' => collect(range($months - 1, 0))->map(fn($i) => now()->subMonths($i)->format('M Y'))->toArray(),
|
||||
'clients' => $this->getMonthlyClients($months),
|
||||
'consultations' => $this->getMonthlyConsultations($months),
|
||||
];
|
||||
use App\Models\User;
|
||||
use App\Models\Consultation;
|
||||
use App\Services\AnalyticsService;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public string $chartPeriod = '6m';
|
||||
public ?string $customStart = null;
|
||||
public ?string $customEnd = null;
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
// ... existing metrics from Story 6.1 ...
|
||||
'chartData' => $this->getChartData(),
|
||||
];
|
||||
}
|
||||
|
||||
public function updatedChartPeriod(): void
|
||||
{
|
||||
// Livewire will re-render with new data
|
||||
}
|
||||
|
||||
public function setCustomRange(string $start, string $end): void
|
||||
{
|
||||
$this->customStart = $start;
|
||||
$this->customEnd = $end;
|
||||
$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->customStart
|
||||
? Carbon::parse($this->customStart)->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->customStart || !$this->customEnd) {
|
||||
return 6;
|
||||
}
|
||||
return Carbon::parse($this->customStart)
|
||||
->diffInMonths(Carbon::parse($this->customEnd)) + 1;
|
||||
}
|
||||
}; ?>
|
||||
```
|
||||
|
||||
### Analytics Service
|
||||
|
||||
Create `app/Services/AnalyticsService.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Consultation;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AnalyticsService
|
||||
{
|
||||
public function getMonthLabels(Carbon $startDate, int $months): array
|
||||
{
|
||||
return collect(range(0, $months - 1))
|
||||
->map(fn($i) => $startDate->copy()->addMonths($i)->translatedFormat('M Y'))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getMonthlyNewClients(Carbon $startDate, int $months): array
|
||||
{
|
||||
$endDate = $startDate->copy()->addMonths($months);
|
||||
|
||||
$data = User::query()
|
||||
->whereIn('user_type', ['individual', 'company'])
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count")
|
||||
->groupBy('month')
|
||||
->pluck('count', 'month');
|
||||
|
||||
return $this->fillMonthlyData($startDate, $months, $data);
|
||||
}
|
||||
|
||||
public function getMonthlyConsultations(Carbon $startDate, int $months): array
|
||||
{
|
||||
$endDate = $startDate->copy()->addMonths($months);
|
||||
|
||||
$data = Consultation::query()
|
||||
->whereBetween('scheduled_date', [$startDate, $endDate])
|
||||
->selectRaw("DATE_FORMAT(scheduled_date, '%Y-%m') as month, COUNT(*) as count")
|
||||
->groupBy('month')
|
||||
->pluck('count', 'month');
|
||||
|
||||
return $this->fillMonthlyData($startDate, $months, $data);
|
||||
}
|
||||
|
||||
public function getConsultationTypeBreakdown(Carbon $startDate, int $months): array
|
||||
{
|
||||
$endDate = $startDate->copy()->addMonths($months);
|
||||
|
||||
return [
|
||||
'free' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
|
||||
->where('consultation_type', 'free')->count(),
|
||||
'paid' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
|
||||
->where('consultation_type', 'paid')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getMonthlyNoShowRates(Carbon $startDate, int $months): array
|
||||
{
|
||||
$endDate = $startDate->copy()->addMonths($months);
|
||||
|
||||
$results = [];
|
||||
for ($i = 0; $i < $months; $i++) {
|
||||
$monthStart = $startDate->copy()->addMonths($i);
|
||||
$monthEnd = $monthStart->copy()->endOfMonth();
|
||||
|
||||
$total = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
|
||||
->whereIn('status', ['completed', 'no-show'])
|
||||
->count();
|
||||
|
||||
$noShows = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
|
||||
->where('status', 'no-show')
|
||||
->count();
|
||||
|
||||
$results[] = $total > 0 ? round(($noShows / $total) * 100, 1) : 0;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function fillMonthlyData(Carbon $startDate, int $months, Collection $data): array
|
||||
{
|
||||
return collect(range(0, $months - 1))
|
||||
->map(fn($i) => $data->get($startDate->copy()->addMonths($i)->format('Y-m'), 0))
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Chart.js Integration with Livewire
|
||||
|
||||
Use `wire:ignore` to prevent Livewire from re-rendering chart canvas, and Alpine.js to manage Chart.js instances:
|
||||
|
||||
```blade
|
||||
{{-- Charts Section --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8">
|
||||
{{-- Date Range Selector --}}
|
||||
<div class="lg:col-span-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button
|
||||
wire:click="$set('chartPeriod', '6m')"
|
||||
:variant="$chartPeriod === '6m' ? 'primary' : 'ghost'"
|
||||
>
|
||||
{{ __('Last 6 Months') }}
|
||||
</flux:button>
|
||||
<flux:button
|
||||
wire:click="$set('chartPeriod', '12m')"
|
||||
:variant="$chartPeriod === '12m' ? 'primary' : 'ghost'"
|
||||
>
|
||||
{{ __('Last 12 Months') }}
|
||||
</flux:button>
|
||||
{{-- Custom range picker would go here --}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Monthly Trends Chart --}}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
|
||||
<flux:heading size="sm">{{ __('Monthly Trends') }}</flux:heading>
|
||||
<div
|
||||
wire:ignore
|
||||
x-data="trendsChart(@js($chartData))"
|
||||
x-init="init()"
|
||||
class="h-64 mt-4"
|
||||
>
|
||||
<canvas x-ref="canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Consultation Breakdown Chart --}}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
|
||||
<flux:heading size="sm">{{ __('Consultation Breakdown') }}</flux:heading>
|
||||
<div
|
||||
wire:ignore
|
||||
x-data="breakdownChart(@js($chartData['consultationBreakdown']))"
|
||||
x-init="init()"
|
||||
class="h-64 mt-4"
|
||||
>
|
||||
<canvas x-ref="canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- No-show Rate Chart --}}
|
||||
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
|
||||
<flux:heading size="sm">{{ __('No-show Rate Trend') }}</flux:heading>
|
||||
<div
|
||||
wire:ignore
|
||||
x-data="noShowChart(@js($chartData))"
|
||||
x-init="init()"
|
||||
class="h-64 mt-4"
|
||||
>
|
||||
<canvas x-ref="canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('trendsChart', (data) => ({
|
||||
chart: null,
|
||||
init() {
|
||||
this.chart = new Chart(this.$refs.canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{{ __("New Clients") }}',
|
||||
data: data.newClients,
|
||||
borderColor: '#D4AF37',
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: '{{ __("Consultations") }}',
|
||||
data: data.consultations,
|
||||
borderColor: '#0A1F44',
|
||||
tension: 0.3,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: { enabled: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
Alpine.data('breakdownChart', (data) => ({
|
||||
chart: null,
|
||||
init() {
|
||||
this.chart = new Chart(this.$refs.canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['{{ __("Free") }}', '{{ __("Paid") }}'],
|
||||
datasets: [{
|
||||
data: [data.free, data.paid],
|
||||
backgroundColor: ['#D4AF37', '#0A1F44'],
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
Alpine.data('noShowChart', (data) => ({
|
||||
chart: null,
|
||||
init() {
|
||||
this.chart = new Chart(this.$refs.canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: '{{ __("No-show Rate %") }}',
|
||||
data: data.noShowRates,
|
||||
borderColor: '#E74C3C',
|
||||
backgroundColor: 'rgba(231, 76, 60, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { min: 0, max: 100 }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
```
|
||||
|
||||
### NPM Dependencies
|
||||
|
||||
Chart.js can be loaded via CDN (as shown) or installed via npm:
|
||||
|
||||
```bash
|
||||
npm install chart.js
|
||||
```
|
||||
|
||||
Then import in `resources/js/app.js`:
|
||||
```js
|
||||
import Chart from 'chart.js/auto';
|
||||
window.Chart = Chart;
|
||||
```
|
||||
|
||||
## Edge Cases & Error Handling
|
||||
|
||||
| Scenario | Expected Behavior |
|
||||
|----------|-------------------|
|
||||
| No data for selected period | Show "No data available" message in chart area |
|
||||
| Only one month of data | Chart renders single point with label |
|
||||
| Zero consultations (division by zero for no-show rate) | Return 0% no-show rate, not error |
|
||||
| Very large numbers | Y-axis scales appropriately with Chart.js auto-scaling |
|
||||
| Custom range spans years | Labels show "Jan 2024", "Jan 2025" to distinguish |
|
||||
| RTL language (Arabic) | Chart labels render correctly, legend on appropriate side |
|
||||
| Chart.js fails to load | Show fallback message "Charts unavailable" |
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Test File
|
||||
`tests/Feature/Admin/AnalyticsChartsTest.php`
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Consultation;
|
||||
use App\Services\AnalyticsService;
|
||||
use Livewire\Volt\Volt;
|
||||
use Carbon\Carbon;
|
||||
|
||||
test('admin can view analytics charts section', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertSuccessful()
|
||||
->assertSee(__('Monthly Trends'));
|
||||
});
|
||||
|
||||
test('chart data returns correct monthly client counts', function () {
|
||||
// Create clients across different months
|
||||
User::factory()->create([
|
||||
'user_type' => 'individual',
|
||||
'created_at' => now()->subMonths(2),
|
||||
]);
|
||||
User::factory()->count(3)->create([
|
||||
'user_type' => 'individual',
|
||||
'created_at' => now()->subMonth(),
|
||||
]);
|
||||
User::factory()->count(2)->create([
|
||||
'user_type' => 'company',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$service = new AnalyticsService();
|
||||
$data = $service->getMonthlyNewClients(now()->subMonths(2)->startOfMonth(), 3);
|
||||
|
||||
expect($data)->toBe([1, 3, 2]);
|
||||
});
|
||||
|
||||
test('consultation breakdown calculates free vs paid correctly', function () {
|
||||
Consultation::factory()->count(5)->create(['consultation_type' => 'free']);
|
||||
Consultation::factory()->count(3)->create(['consultation_type' => 'paid']);
|
||||
|
||||
$service = new AnalyticsService();
|
||||
$breakdown = $service->getConsultationTypeBreakdown(now()->subYear(), 12);
|
||||
|
||||
expect($breakdown['free'])->toBe(5);
|
||||
expect($breakdown['paid'])->toBe(3);
|
||||
});
|
||||
|
||||
test('no-show rate calculates correctly', function () {
|
||||
// Create 8 completed, 2 no-shows = 20% rate
|
||||
Consultation::factory()->count(8)->create([
|
||||
'status' => 'completed',
|
||||
'scheduled_date' => now(),
|
||||
]);
|
||||
Consultation::factory()->count(2)->create([
|
||||
'status' => 'no-show',
|
||||
'scheduled_date' => now(),
|
||||
]);
|
||||
|
||||
$service = new AnalyticsService();
|
||||
$rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1);
|
||||
|
||||
expect($rates[0])->toBe(20.0);
|
||||
});
|
||||
|
||||
test('no-show rate returns zero when no consultations exist', function () {
|
||||
$service = new AnalyticsService();
|
||||
$rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1);
|
||||
|
||||
expect($rates[0])->toBe(0);
|
||||
});
|
||||
|
||||
test('date range selector changes chart period', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
Volt::test('admin.dashboard')
|
||||
->actingAs($admin)
|
||||
->assertSet('chartPeriod', '6m')
|
||||
->set('chartPeriod', '12m')
|
||||
->assertSet('chartPeriod', '12m');
|
||||
});
|
||||
|
||||
test('chart handles empty data gracefully', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
// No clients or consultations created
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertSuccessful();
|
||||
// Should not throw errors
|
||||
});
|
||||
|
||||
test('non-admin cannot access analytics charts', function () {
|
||||
$client = User::factory()->create(['user_type' => 'individual']);
|
||||
|
||||
$this->actingAs($client)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertForbidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
- [ ] Verify charts render on desktop (1200px+)
|
||||
- [ ] Verify charts resize correctly on tablet (768px)
|
||||
- [ ] Verify charts stack vertically on mobile (375px)
|
||||
- [ ] Verify tooltips show exact values on hover
|
||||
- [ ] Verify 6-month button is selected by default
|
||||
- [ ] Verify 12-month button updates all charts
|
||||
- [ ] Verify chart colors match brand (navy #0A1F44, gold #D4AF37)
|
||||
- [ ] Verify charts work in Arabic (RTL) mode
|
||||
- [ ] Verify loading state appears while data fetches
|
||||
- [ ] Verify empty state message when no data
|
||||
|
||||
## Definition of Done
|
||||
- [ ] All charts render correctly
|
||||
- [ ] Date range selector works
|
||||
- [ ] Tooltips functional
|
||||
- [ ] Mobile responsive
|
||||
- [ ] Tests pass
|
||||
- [ ] All three charts render correctly (trends, breakdown, no-show)
|
||||
- [ ] Date range selector switches between 6/12 months
|
||||
- [ ] Tooltips show exact values on all charts
|
||||
- [ ] Charts are responsive on mobile, tablet, desktop
|
||||
- [ ] Bilingual labels work (Arabic/English)
|
||||
- [ ] Empty state handled gracefully
|
||||
- [ ] No-show rate handles zero consultations (no division error)
|
||||
- [ ] AnalyticsService unit tests pass
|
||||
- [ ] Feature tests for chart data pass
|
||||
- [ ] Code formatted with Pint
|
||||
- [ ] Admin-only access enforced
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium-High | **Effort:** 4-5 hours
|
||||
|
||||
## Out of Scope
|
||||
- Custom date range picker with calendar UI (can use simple month selects)
|
||||
- Exporting charts as images
|
||||
- Real-time chart updates (polling) - charts update on page load/range change only
|
||||
- Animated chart transitions
|
||||
|
||||
Reference in New Issue
Block a user