# WhatsApp Insights Page Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a channel-scoped React page that shows a WABA's phone-numbers list and pricing analytics (cost & volume by country/category over time) with server-side filters.

**Architecture:** Blade host div mounts a React bundle (wa-flows pattern). React calls a session-guarded JSON endpoint under the `admin/{channel}` route group, which proxies two Meta Graph API endpoints via a thin `WaInsightsService`, applies server-side PHP filtering, and returns normalized JSON.

**Tech Stack:** Laravel 8, Blade/AdminLTE, React 19, Chart.js, Laravel Mix, axios, PHPUnit, `Http::fake()`.

## Global Constraints

- Graph base URL: `https://graph.facebook.com/` . `config('whatsapp.graph_version')` . `/` (currently `v22.0`).
- Auth to Meta: `Http::withToken($account->wa_access_token)`.
- Token + WABA id come from `$channel->waAccount` (`wa_access_token`, `wa_waba_id`).
- Never run `npm run development`; this repo commits versioned production assets. Build with `npm run production`.
- New services live in `app/Skale/Services/` (alongside `WaFlowPublisher`).
- Frontend bundles follow the wa-flows convention: `resources/js/<name>/index.jsx` → `public/js/<name>/index.js`, registered in `webpack.mix.js` with `.react()`.

---

### Task 1: `WaInsightsService` — Meta Graph proxy

**Files:**
- Create: `app/Skale/Services/WaInsightsService.php`
- Test: `tests/Unit/WaInsights/WaInsightsServiceTest.php`

**Interfaces:**
- Consumes: `App\Models\WaAccounts` (`wa_access_token`, `wa_waba_id`), `config('whatsapp.graph_version')`.
- Produces:
  - `phoneNumbers(WaAccounts $waba): array` — returns the raw `data` array from Meta; each item: `['display_phone_number' => string, 'verified_name' => string, 'quality_rating' => string, 'status' => string, 'throughput' => array]`. Returns `[]` when Meta omits `data`.
  - `pricingAnalytics(WaAccounts $waba, int $start, int $end, string $granularity): array` — returns a flat list of points, each `['start' => int, 'end' => int, 'cost' => float, 'volume' => int, 'country' => string, 'pricing_category' => string]`. Returns `[]` when Meta omits the data.

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Unit\WaInsights;

use App\Models\WaAccounts;
use App\Skale\Services\WaInsightsService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class WaInsightsServiceTest extends TestCase
{
    private function account(): WaAccounts
    {
        $a = new WaAccounts();
        $a->wa_access_token = 'TOKEN';
        $a->wa_waba_id = 'WABA123';
        return $a;
    }

    public function test_phone_numbers_returns_data_array(): void
    {
        Http::fake([
            'graph.facebook.com/*/WABA123/phone_numbers*' => Http::response([
                'data' => [
                    ['display_phone_number' => '+1 555', 'verified_name' => 'Acme',
                     'quality_rating' => 'GREEN', 'status' => 'CONNECTED', 'throughput' => ['level' => 'STANDARD']],
                ],
            ], 200),
        ]);

        $rows = (new WaInsightsService())->phoneNumbers($this->account());

        $this->assertCount(1, $rows);
        $this->assertSame('Acme', $rows[0]['verified_name']);
    }

    public function test_phone_numbers_missing_data_returns_empty(): void
    {
        Http::fake(['graph.facebook.com/*' => Http::response([], 200)]);
        $this->assertSame([], (new WaInsightsService())->phoneNumbers($this->account()));
    }

    public function test_pricing_analytics_flattens_data_points(): void
    {
        Http::fake([
            'graph.facebook.com/*WABA123*' => Http::response([
                'pricing_analytics' => [
                    'data' => [[
                        'data_points' => [
                            ['start' => 1767225600, 'end' => 1769904000, 'cost' => 12.5, 'volume' => 100,
                             'country' => 'US', 'pricing_category' => 'MARKETING'],
                            ['start' => 1767225600, 'end' => 1769904000, 'cost' => 3.0, 'volume' => 20,
                             'country' => 'MX', 'pricing_category' => 'UTILITY'],
                        ],
                    ]],
                ],
                'id' => 'WABA123',
            ], 200),
        ]);

        $points = (new WaInsightsService())->pricingAnalytics($this->account(), 1767225600, 1783670400, 'MONTHLY');

        $this->assertCount(2, $points);
        $this->assertSame('US', $points[0]['country']);
        $this->assertSame(12.5, $points[0]['cost']);
    }

    public function test_pricing_analytics_missing_data_returns_empty(): void
    {
        Http::fake(['graph.facebook.com/*' => Http::response(['id' => 'WABA123'], 200)]);
        $this->assertSame([], (new WaInsightsService())->pricingAnalytics($this->account(), 1, 2, 'MONTHLY'));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `./vendor/bin/phpunit tests/Unit/WaInsights/WaInsightsServiceTest.php`
Expected: FAIL with "Class ... WaInsightsService not found".

- [ ] **Step 3: Write minimal implementation**

```php
<?php

namespace App\Skale\Services;

use App\Models\WaAccounts;
use Illuminate\Support\Facades\Http;

class WaInsightsService
{
    protected string $host;

    public function __construct()
    {
        $this->host = 'https://graph.facebook.com/' . config('whatsapp.graph_version') . '/';
    }

    /** @return array<int,array> */
    public function phoneNumbers(WaAccounts $waba): array
    {
        $resp = Http::withToken($waba->wa_access_token)
            ->get($this->host . $waba->wa_waba_id . '/phone_numbers', [
                'fields' => 'display_phone_number,verified_name,quality_rating,status,throughput',
            ]);

        return $resp->json('data') ?? [];
    }

    /** @return array<int,array{start:int,end:int,cost:float,volume:int,country:string,pricing_category:string}> */
    public function pricingAnalytics(WaAccounts $waba, int $start, int $end, string $granularity): array
    {
        $field = sprintf(
            'pricing_analytics.start(%d).end(%d).granularity(%s).metric_types(["COST","VOLUME"]).dimensions(["COUNTRY","PRICING_CATEGORY"])',
            $start, $end, $granularity
        );

        $resp = Http::withToken($waba->wa_access_token)
            ->get($this->host . $waba->wa_waba_id, ['fields' => $field]);

        $points = [];
        foreach ($resp->json('pricing_analytics.data') ?? [] as $group) {
            foreach ($group['data_points'] ?? [] as $p) {
                $points[] = [
                    'start' => (int) ($p['start'] ?? 0),
                    'end' => (int) ($p['end'] ?? 0),
                    'cost' => (float) ($p['cost'] ?? 0),
                    'volume' => (int) ($p['volume'] ?? 0),
                    'country' => (string) ($p['country'] ?? ''),
                    'pricing_category' => (string) ($p['pricing_category'] ?? ''),
                ];
            }
        }

        return $points;
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `./vendor/bin/phpunit tests/Unit/WaInsights/WaInsightsServiceTest.php`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add app/Skale/Services/WaInsightsService.php tests/Unit/WaInsights/WaInsightsServiceTest.php
git commit -m "feat: add WaInsightsService proxying Meta phone_numbers and pricing_analytics"
```

---

### Task 2: Controller + routes + server-side filtering

**Files:**
- Create: `app/Http/Controllers/Channel/WaInsightsController.php`
- Modify: `routes/web.php` (inside the `admin/{channel}` group, near the `flow` routes ~line 141)
- Test: `tests/Feature/WaInsights/WaInsightsDataTest.php`

**Interfaces:**
- Consumes: `WaInsightsService` (Task 1), `App\Models\Channels`, `$channel->waAccount`.
- Produces:
  - `GET admin/{channel}/insights` → `index(Channels $channel)` renders the host Blade (Task 3), name `channel.insights.index`.
  - `GET admin/{channel}/insights/data` → `data(Request, Channels $channel)` returns JSON:
    `{ pricing: { points: [...], breakdown: [...], totals: {cost, volume} }, phoneNumbers: [...] }`
    or `{ error: string }` on missing account / Meta failure. Name `channel.insights.data`.

**Filtering rules (server-side, in `data`):**
- Query params: `start`, `end` (unix; default last 12 months), `granularity` (default `MONTHLY`), `countries[]`, `categories[]`, `search`, `quality`, `status`.
- Pricing points filtered by `countries` / `categories` when present.
- `points` = points summed by `start` (period); `breakdown` = summed by `country`+`pricing_category`; `totals` = grand sum.
- Phone numbers filtered by `search` (case-insensitive over `display_phone_number` + `verified_name`), `quality`, `status`.

- [ ] **Step 1: Write the failing test**

```php
<?php

namespace Tests\Feature\WaInsights;

use App\Models\Channels;
use App\Models\WaAccounts;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class WaInsightsDataTest extends TestCase
{
    public function test_data_endpoint_returns_filtered_pricing_and_phone_numbers(): void
    {
        // Arrange: authenticated user with access to a channel bound to a WABA account.
        [$user, $channel] = $this->seedUserWithChannel();

        Http::fake([
            'graph.facebook.com/*/phone_numbers*' => Http::response([
                'data' => [
                    ['display_phone_number' => '+1 555', 'verified_name' => 'Acme',
                     'quality_rating' => 'GREEN', 'status' => 'CONNECTED', 'throughput' => ['level' => 'STANDARD']],
                    ['display_phone_number' => '+52 33', 'verified_name' => 'Beta',
                     'quality_rating' => 'RED', 'status' => 'FLAGGED', 'throughput' => ['level' => 'STANDARD']],
                ],
            ], 200),
            'graph.facebook.com/*' => Http::response([
                'pricing_analytics' => ['data' => [['data_points' => [
                    ['start' => 1767225600, 'end' => 1769904000, 'cost' => 10.0, 'volume' => 100,
                     'country' => 'US', 'pricing_category' => 'MARKETING'],
                    ['start' => 1767225600, 'end' => 1769904000, 'cost' => 5.0, 'volume' => 50,
                     'country' => 'MX', 'pricing_category' => 'UTILITY'],
                ]]]],
                'id' => 'WABA123',
            ], 200),
        ]);

        // Act
        $resp = $this->actingAs($user)->getJson(
            route('channel.insights.data', ['channel' => $channel]) . '?quality=GREEN&countries[]=US'
        );

        // Assert
        $resp->assertOk();
        $resp->assertJsonPath('pricing.totals.cost', 10.0);            // MX filtered out by countries[]=US
        $resp->assertJsonCount(1, 'phoneNumbers');                     // Beta filtered out by quality=GREEN
        $resp->assertJsonPath('phoneNumbers.0.verified_name', 'Acme');
    }

    public function test_data_endpoint_reports_error_when_no_account(): void
    {
        [$user, $channel] = $this->seedUserWithChannel(withAccount: false);

        $resp = $this->actingAs($user)->getJson(route('channel.insights.data', ['channel' => $channel]));

        $resp->assertOk();
        $resp->assertJsonStructure(['error']);
    }

    /**
     * Build a user, company, WABA account, and channel with resource access.
     * Mirror the setup used by existing channel feature tests — reuse their
     * factory/helper if one exists (grep tests/ for actingAs + Channels).
     * @return array{0:User,1:Channels}
     */
    private function seedUserWithChannel(bool $withAccount = true): array
    {
        // Implementation follows the repo's existing channel test setup.
        // Fill using the same factories other Feature tests use.
        // ... see existing tests/Feature for the exact company/user/channel wiring.
        $account = $withAccount ? WaAccounts::factory()->create([
            'wa_access_token' => 'TOKEN', 'wa_waba_id' => 'WABA123',
        ]) : null;

        $channel = Channels::factory()->create([
            'wa_account_id' => $account?->id,
        ]);

        $user = User::factory()->create();
        // grant company/channel access as existing tests do
        return [$user, $channel];
    }
}
```

> Note for the implementer: `seedUserWithChannel` must produce a user who passes `UserResourceAccessMiddleware` for `$channel` (company access + channel-belongs-to-user). Before writing it, grep `tests/Feature` and `tests/Unit` for existing `Channels::factory()` / `actingAs` setups and copy that wiring exactly. If there is no reusable helper and factories are missing, create the minimal rows directly (company, user-company pivot, channel, wa_account) matching the schema.

- [ ] **Step 2: Run test to verify it fails**

Run: `./vendor/bin/phpunit tests/Feature/WaInsights/WaInsightsDataTest.php`
Expected: FAIL — route `channel.insights.data` not defined.

- [ ] **Step 3: Add the routes**

In `routes/web.php`, inside the `Route::group(... 'prefix' => 'admin/{channel}' ...)` block (the one with `UserResourceAccessMiddleware`, near the `flow` routes), add:

```php
  // WhatsApp Insights (phone numbers + pricing analytics)
  Route::get('insights', [\App\Http\Controllers\Channel\WaInsightsController::class, 'index'])
      ->name('channel.insights.index');
  Route::get('insights/data', [\App\Http\Controllers\Channel\WaInsightsController::class, 'data'])
      ->name('channel.insights.data');
```

- [ ] **Step 4: Write the controller**

```php
<?php

namespace App\Http\Controllers\Channel;

use App\Http\Controllers\Controller;
use App\Models\Channels;
use App\Skale\Services\WaInsightsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class WaInsightsController extends Controller
{
    public function index(Channels $channel)
    {
        return view('admin.wa-insights.index', ['channel' => $channel]);
    }

    public function data(Request $request, Channels $channel, WaInsightsService $service): JsonResponse
    {
        $account = $channel->waAccount;
        if (!$account || !$account->wa_access_token || !$account->wa_waba_id) {
            return response()->json(['error' => 'No WhatsApp account connected to this channel.']);
        }

        $start = (int) $request->query('start', now()->subMonths(12)->startOfMonth()->timestamp);
        $end = (int) $request->query('end', now()->timestamp);
        $granularity = (string) $request->query('granularity', 'MONTHLY');
        $countries = (array) $request->query('countries', []);
        $categories = (array) $request->query('categories', []);

        try {
            $points = $service->pricingAnalytics($account, $start, $end, $granularity);
            $phones = $service->phoneNumbers($account);
        } catch (\Throwable $e) {
            return response()->json(['error' => 'Could not load data from Meta: ' . $e->getMessage()]);
        }

        // Pricing filters
        $points = array_values(array_filter($points, function ($p) use ($countries, $categories) {
            return (empty($countries) || in_array($p['country'], $countries, true))
                && (empty($categories) || in_array($p['pricing_category'], $categories, true));
        }));

        // Phone-number filters
        $search = Str::lower((string) $request->query('search', ''));
        $quality = (string) $request->query('quality', '');
        $status = (string) $request->query('status', '');
        $phones = array_values(array_filter($phones, function ($n) use ($search, $quality, $status) {
            $hay = Str::lower(($n['display_phone_number'] ?? '') . ' ' . ($n['verified_name'] ?? ''));
            return ($search === '' || Str::contains($hay, $search))
                && ($quality === '' || ($n['quality_rating'] ?? '') === $quality)
                && ($status === '' || ($n['status'] ?? '') === $status);
        }));

        return response()->json([
            'pricing' => [
                'points' => $this->sumBy($points, fn ($p) => (string) $p['start'], ['start', 'end']),
                'breakdown' => $this->sumBy($points, fn ($p) => $p['country'] . '|' . $p['pricing_category'], ['country', 'pricing_category']),
                'totals' => [
                    'cost' => round(array_sum(array_column($points, 'cost')), 4),
                    'volume' => (int) array_sum(array_column($points, 'volume')),
                ],
            ],
            'phoneNumbers' => $phones,
        ]);
    }

    /**
     * Group points by $keyFn, summing cost+volume, keeping the $carry dimension fields.
     * @param array<int,array> $points
     * @param array<int,string> $carry
     * @return array<int,array>
     */
    private function sumBy(array $points, callable $keyFn, array $carry): array
    {
        $out = [];
        foreach ($points as $p) {
            $k = $keyFn($p);
            if (!isset($out[$k])) {
                $out[$k] = ['cost' => 0.0, 'volume' => 0];
                foreach ($carry as $c) {
                    $out[$k][$c] = $p[$c];
                }
            }
            $out[$k]['cost'] += $p['cost'];
            $out[$k]['volume'] += $p['volume'];
        }
        foreach ($out as &$row) {
            $row['cost'] = round($row['cost'], 4);
        }
        return array_values($out);
    }
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `./vendor/bin/phpunit tests/Feature/WaInsights/WaInsightsDataTest.php`
Expected: PASS (2 tests). Fix the `seedUserWithChannel` wiring first if access-control fails (403).

- [ ] **Step 6: Commit**

```bash
git add app/Http/Controllers/Channel/WaInsightsController.php routes/web.php tests/Feature/WaInsights/WaInsightsDataTest.php
git commit -m "feat: add WA insights controller, routes, and server-side filtering"
```

---

### Task 3: Host Blade, mix registration, sidebar link

**Files:**
- Create: `resources/views/admin/wa-insights/index.blade.php`
- Modify: `webpack.mix.js` (append a `.js(...).react()` for wa-insights)
- Modify: `resources/views/admin/components/sidebar_menu.blade.php` (add nav link)

**Interfaces:**
- Consumes: `$channel` (from Task 2 `index`), route `channel.insights.data` (Task 2), bundle `public/js/wa-insights/index.js` (built in Task 4).
- Produces: `#wa-insights-wrapper` mount div with `data-channel-id` and `data-endpoint` attributes for React (Task 4).

- [ ] **Step 1: Create the host Blade**

```blade
@extends('layouts.admin')

@section('style')
    @parent
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
@endsection

@section('content')
    <div id="wa-insights-wrapper"
         data-channel-id="{{ $channel->id }}"
         data-endpoint="{{ route('channel.insights.data', ['channel' => $channel], false) }}"></div>
@endsection

@section('script')
    @parent
    <script src="{{ mix('js/wa-insights/index.js') }}"></script>
@endsection
```

- [ ] **Step 2: Register the bundle in `webpack.mix.js`**

Append after the existing `.js("resources/js/dashboard/index.jsx", ...)` `.react()` chain:

```js
mix.js("resources/js/wa-insights/index.jsx", "public/js/wa-insights").react();
```

- [ ] **Step 3: Add the sidebar link**

In `resources/views/admin/components/sidebar_menu.blade.php`, add a nav item next to the existing channel links (match the surrounding markup exactly — same `<li class="nav-item">` / `<a class="nav-link">` structure the file already uses):

```blade
<li class="nav-item">
    <a href="{{ route('channel.insights.index', ['channel' => $channel]) }}" class="nav-link">
        <i class="nav-icon fas fa-chart-line"></i>
        <p>Insights</p>
    </a>
</li>
```

> Grep the file first for how `$channel` is referenced and how the active state is applied; copy that convention.

- [ ] **Step 4: Verify the route renders (no bundle yet is OK)**

Run: `php artisan route:list --name=channel.insights`
Expected: both `channel.insights.index` and `channel.insights.data` listed.

- [ ] **Step 5: Commit**

```bash
git add resources/views/admin/wa-insights/index.blade.php webpack.mix.js resources/views/admin/components/sidebar_menu.blade.php
git commit -m "feat: add WA insights blade host, mix bundle registration, sidebar link"
```

---

### Task 4: React frontend

**Files:**
- Create: `resources/js/wa-insights/index.jsx`
- Create: `resources/js/wa-insights/api/client.js`
- Create: `resources/js/wa-insights/App.jsx`
- Create: `resources/js/wa-insights/FilterBar.jsx`
- Create: `resources/js/wa-insights/PricingSection.jsx`
- Create: `resources/js/wa-insights/PhoneNumbersSection.jsx`

**Interfaces:**
- Consumes: mount div `#wa-insights-wrapper` (`data-channel-id`, `data-endpoint`), JSON from `channel.insights.data`, `chart.js` (installed).
- Produces: rendered page. No exports consumed by other tasks.

Apply the `frontend-design` skill for the visual layer (typography, spacing, panel styling) to match the wa-flows look — but keep component structure exactly as below.

- [ ] **Step 1: `index.jsx` (mount)**

```jsx
import ReactDOM from "react-dom/client";
import App from "./App";

const el = document.getElementById("wa-insights-wrapper");
if (el) {
    const channelId = el.getAttribute("data-channel-id");
    const endpoint = el.getAttribute("data-endpoint");
    ReactDOM.createRoot(el).render(<App channelId={channelId} endpoint={endpoint} />);
}
```

- [ ] **Step 2: `api/client.js`**

```js
import axios from "axios";

axios.defaults.headers.common["X-CSRF-TOKEN"] =
    document.querySelector('meta[name="csrf-token"]')?.content;

// params: {start, end, granularity, countries[], categories[], search, quality, status}
export const loadInsights = (endpoint, params) =>
    axios.get(endpoint, { params }).then((r) => r.data);
```

- [ ] **Step 3: `App.jsx`**

```jsx
import { useEffect, useState, useCallback } from "react";
import { loadInsights } from "./api/client";
import FilterBar from "./FilterBar";
import PricingSection from "./PricingSection";
import PhoneNumbersSection from "./PhoneNumbersSection";

const monthsAgo = (n) => Math.floor(new Date(new Date().setMonth(new Date().getMonth() - n)).getTime() / 1000);

export default function App({ endpoint }) {
    const [filters, setFilters] = useState({
        start: monthsAgo(12),
        end: Math.floor(Date.now() / 1000),
        granularity: "MONTHLY",
        countries: [],
        categories: [],
        search: "",
        quality: "",
        status: "",
    });
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    const fetchData = useCallback((f) => {
        setLoading(true);
        setError(null);
        loadInsights(endpoint, f)
            .then((res) => {
                if (res.error) setError(res.error);
                else setData(res);
            })
            .catch((e) => setError(e?.message ?? "Request failed"))
            .finally(() => setLoading(false));
    }, [endpoint]);

    useEffect(() => { fetchData(filters); }, []); // initial load

    const onApply = (next) => { setFilters(next); fetchData(next); };

    return (
        <div className="wa-insights">
            <FilterBar filters={filters} onApply={onApply} disabled={loading} />
            {error && <div className="alert alert-danger" role="alert">{error}</div>}
            {loading && <div className="text-muted">Loading…</div>}
            {data && !error && (
                <>
                    <PricingSection pricing={data.pricing} />
                    <PhoneNumbersSection numbers={data.phoneNumbers} />
                </>
            )}
        </div>
    );
}
```

- [ ] **Step 4: `FilterBar.jsx`**

```jsx
import { useState } from "react";

const GRANULARITIES = ["MONTHLY", "DAILY", "HALF_HOUR"];
const QUALITIES = ["", "GREEN", "YELLOW", "RED"];

const toDateInput = (unix) => new Date(unix * 1000).toISOString().slice(0, 10);
const toUnix = (dateStr) => Math.floor(new Date(dateStr).getTime() / 1000);

export default function FilterBar({ filters, onApply, disabled }) {
    const [f, setF] = useState(filters);
    const set = (k, v) => setF((prev) => ({ ...prev, [k]: v }));

    return (
        <form
            className="wa-insights-filters card card-body"
            onSubmit={(e) => { e.preventDefault(); onApply(f); }}
        >
            <div className="d-flex flex-wrap gap-2 align-items-end">
                <label>Start
                    <input type="date" className="form-control" value={toDateInput(f.start)}
                           onChange={(e) => set("start", toUnix(e.target.value))} />
                </label>
                <label>End
                    <input type="date" className="form-control" value={toDateInput(f.end)}
                           onChange={(e) => set("end", toUnix(e.target.value))} />
                </label>
                <label>Granularity
                    <select className="form-control" value={f.granularity}
                            onChange={(e) => set("granularity", e.target.value)}>
                        {GRANULARITIES.map((g) => <option key={g} value={g}>{g}</option>)}
                    </select>
                </label>
                <label>Phone search
                    <input type="text" className="form-control" value={f.search}
                           onChange={(e) => set("search", e.target.value)} placeholder="name or number" />
                </label>
                <label>Quality
                    <select className="form-control" value={f.quality}
                            onChange={(e) => set("quality", e.target.value)}>
                        {QUALITIES.map((q) => <option key={q} value={q}>{q || "Any"}</option>)}
                    </select>
                </label>
                <label>Status
                    <input type="text" className="form-control" value={f.status}
                           onChange={(e) => set("status", e.target.value)} placeholder="e.g. CONNECTED" />
                </label>
                <button type="submit" className="btn btn-primary" disabled={disabled}>Apply</button>
            </div>
        </form>
    );
}
```

> `countries` / `categories` multiselects: derive their options from the returned `pricing.breakdown` after first load and add two `<select multiple>` controls following the same pattern. Keep them optional — an empty selection means "all".

- [ ] **Step 5: `PricingSection.jsx` (KPI tiles + Chart.js + breakdown table)**

```jsx
import { useEffect, useRef } from "react";
import Chart from "chart.js/auto";

const fmtMonth = (unix) => new Date(unix * 1000).toLocaleDateString(undefined, { year: "numeric", month: "short" });

export default function PricingSection({ pricing }) {
    const canvasRef = useRef(null);
    const chartRef = useRef(null);

    useEffect(() => {
        if (!canvasRef.current) return;
        const points = [...pricing.points].sort((a, b) => a.start - b.start);
        const labels = points.map((p) => fmtMonth(p.start));

        chartRef.current?.destroy();
        chartRef.current = new Chart(canvasRef.current, {
            data: {
                labels,
                datasets: [
                    { type: "bar", label: "Cost", data: points.map((p) => p.cost), yAxisID: "y" },
                    { type: "line", label: "Volume", data: points.map((p) => p.volume), yAxisID: "y1" },
                ],
            },
            options: {
                responsive: true,
                scales: {
                    y: { position: "left", title: { display: true, text: "Cost" } },
                    y1: { position: "right", grid: { drawOnChartArea: false }, title: { display: true, text: "Volume" } },
                },
            },
        });
        return () => chartRef.current?.destroy();
    }, [pricing]);

    return (
        <section className="wa-insights-pricing">
            <div className="row">
                <div className="col-sm-6"><div className="info-box"><span className="info-box-content">
                    <span className="info-box-text">Total cost</span>
                    <span className="info-box-number">{pricing.totals.cost}</span>
                </span></div></div>
                <div className="col-sm-6"><div className="info-box"><span className="info-box-content">
                    <span className="info-box-text">Total volume</span>
                    <span className="info-box-number">{pricing.totals.volume}</span>
                </span></div></div>
            </div>

            <div className="card card-body"><canvas ref={canvasRef} /></div>

            <div className="card card-body">
                <table className="table table-sm">
                    <thead><tr><th>Country</th><th>Category</th><th>Cost</th><th>Volume</th></tr></thead>
                    <tbody>
                        {pricing.breakdown.map((r, i) => (
                            <tr key={i}>
                                <td>{r.country}</td><td>{r.pricing_category}</td>
                                <td>{r.cost}</td><td>{r.volume}</td>
                            </tr>
                        ))}
                        {pricing.breakdown.length === 0 && (
                            <tr><td colSpan={4} className="text-muted">No pricing data for this range.</td></tr>
                        )}
                    </tbody>
                </table>
            </div>
        </section>
    );
}
```

- [ ] **Step 6: `PhoneNumbersSection.jsx`**

```jsx
const QUALITY_CLASS = { GREEN: "badge bg-success", YELLOW: "badge bg-warning", RED: "badge bg-danger" };

export default function PhoneNumbersSection({ numbers }) {
    return (
        <section className="wa-insights-numbers card card-body">
            <h5>Phone numbers</h5>
            <table className="table table-sm">
                <thead><tr><th>Number</th><th>Name</th><th>Quality</th><th>Status</th><th>Throughput</th></tr></thead>
                <tbody>
                    {numbers.map((n, i) => (
                        <tr key={i}>
                            <td>{n.display_phone_number}</td>
                            <td>{n.verified_name}</td>
                            <td><span className={QUALITY_CLASS[n.quality_rating] ?? "badge bg-secondary"}>
                                {n.quality_rating ?? "—"}</span></td>
                            <td>{n.status}</td>
                            <td>{n.throughput?.level ?? "—"}</td>
                        </tr>
                    ))}
                    {numbers.length === 0 && (
                        <tr><td colSpan={5} className="text-muted">No phone numbers match these filters.</td></tr>
                    )}
                </tbody>
            </table>
        </section>
    );
}
```

- [ ] **Step 7: Build assets**

Run: `npm run production`
Expected: build succeeds; `public/js/wa-insights/index.js` is produced.

- [ ] **Step 8: Commit**

```bash
git add resources/js/wa-insights public/js/wa-insights public/mix-manifest.json
git commit -m "feat: add WA insights React page (filters, pricing charts, phone numbers)"
```

---

### Task 5: End-to-end verification

**Files:** none (verification only).

- [ ] **Step 1: Run the full new test suite**

Run: `./vendor/bin/phpunit tests/Unit/WaInsights tests/Feature/WaInsights`
Expected: all PASS.

- [ ] **Step 2: Manually verify in the app**

Use the `run` / `verify` skill to launch the app, log in, open a channel, click **Insights** in the sidebar, and confirm: charts render, tables populate, and changing a filter + Apply re-requests and updates the view. Confirm the empty-state and error alert paths (e.g. a channel with no WABA account).

- [ ] **Step 3: Clean stray build artifacts**

Per repo memory: after frontend work, verify `public/` contains only the intended `wa-insights` bundle changes plus the updated `mix-manifest.json` — `git status public/` should show nothing unexpected. Revert any unrelated rebuilt assets.

---

## Self-Review Notes

- **Spec coverage:** phone numbers (Task 1/2/4), pricing analytics (Task 1/2/4), server-side filters (Task 2 + FilterBar), KPI tiles + chart + breakdown (Task 4), phone table with quality badges (Task 4), error/empty states (Task 2 + App), routes/sidebar (Task 2/3), tests (Task 1/2/5), frontend-design applied (Task 4). Covered.
- **Types:** `pricing.{points,breakdown,totals}` and `phoneNumbers[]` shapes consistent controller→React. `loadInsights(endpoint, params)` signature matches App usage.
- **Access control:** JSON endpoint lives in the `admin/{channel}` group so `UserResourceAccessMiddleware` enforces company scoping — not re-implemented.
