# Rediseño de dashboards PWA — Fase 2 (enriquecimiento de datos) 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:** Enriquecer los dashboards de la PWA con datos que la Fase 1 dejó pendientes — **utilidad mensual real** (admin), **desglose por cobrador + meta de equipo** (supervisor), y **tendencia/sparkline de cobros** — cableando servicios que **ya existen**.

**Architecture:** Casi todo el trabajo es *wiring*: `CashFlowMetricsService::getInterestEarnedThisMonth()`/`getInterestEarned()`, `CollectionMetricsService::getCollectorPerformanceToday()` y `getTeamCollectionTrend()` ya calculan lo necesario (con caché vía `DashboardCacheService`). Se inyectan/llaman desde `DashboardController` (+ `AdminDashboardMetricsService` para la utilidad), se agregan claves al payload de `/pwa/dashboard`, y el frontend las muestra. Sin migraciones ni nueva infraestructura.

**Tech Stack:** Laravel 13 / PHP 8.3 / PHPUnit (TDD real en backend); Vue 3 `<script setup>` + Tailwind/tokens `pwa.css` (verificación por build + preview). Money VO, Carbon.

**Nota de verificación:** backend con TDD PHPUnit; frontend con `npm run build` + preview (no hay runner JS — igual que Fase 1). Recordar el gotcha de modo oscuro: en `<style scoped>` usar `:global(html.dark .sel)`, nunca `:global(html.dark) .sel`.

## Alcance

**DENTRO:**
- **Admin — utilidad mensual real.** Reemplaza la etiqueta engañosa "Utilidad mes" (que en Fase 1 mostraba `interest_earned`, el interés **acumulado** de todo el portafolio) por el interés **del mes en curso** (`getInterestEarnedThisMonth`), con tendencia mes-vs-mes-anterior (`getInterestEarned` con fechas del mes pasado).
- **Supervisor — desglose por cobrador** (`getCollectorPerformanceToday`, filtrado a su equipo) + **meta de equipo** (suma de `daily_goal` de su scope). Reemplaza la `SectionCard` puente de Fase 1.
- **Admin + Supervisor — tendencia de cobros:** serie de 7 días (`getTeamCollectionTrend`) para un **sparkline**, y el delta "cobrado hoy vs ayer" (▲/▼).

**FUERA (requiere infraestructura de snapshots diarios — su propio plan si se quiere):**
- Tendencias de métricas de **stock** (patrimonio, saldo de mora, cartera) vs período anterior — no son derivables de datos transaccionales; necesitarían una tabla de snapshots diarios + job programado. Los KPIs de Patrimonio/Mora simplemente **no muestran flecha de tendencia** (el prop `trend` de `KpiCard` es opcional).
- Sparkline del **saldo de cartera** histórico (se usa el sparkline de **cobros**, que sí es derivable).

---

### Task 1: Admin — utilidad mensual + tendencia (backend)

Añade `interest_month` al payload admin, con el interés del mes y su tendencia MoM. Se hace en `AdminDashboardMetricsService` (fuente centralizada de métricas admin) inyectando `CashFlowMetricsService`.

**Files:**
- Modify: `app/Services/Dashboard/AdminDashboardMetricsService.php`
- Test: `tests/Feature/Pwa/AdminDashboardMonthlyInterestTest.php`

- [ ] **Step 1: Escribir el test (rojo)**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Enums\IncomeCategory;
use App\Models\Company;
use App\Models\Income;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class AdminDashboardMonthlyInterestTest extends TestCase
{
    use DatabaseTransactions;

    #[Test]
    public function admin_dashboard_exposes_monthly_interest_not_cumulative(): void
    {
        Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);

        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        $company = Company::factory()->create();
        Subscription::factory()->active()->create([
            'company_id' => $company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);
        $admin = User::factory()->create(['company_id' => $company->id]);
        $admin->assignRole('admin');

        // Interés ganado ESTE mes: 300.000; mes pasado: 200.000.
        Income::create([
            'company_id' => $company->id,
            'category' => IncomeCategory::LOAN_PAYMENT_INTEREST->value,
            'amount' => 300000,
            'operation_date' => now()->startOfMonth()->addDays(2)->toDateString(),
            'description' => 'interés mes actual',
        ]);
        Income::create([
            'company_id' => $company->id,
            'category' => IncomeCategory::LOAN_PAYMENT_INTEREST->value,
            'amount' => 200000,
            'operation_date' => now()->subMonthNoOverflow()->startOfMonth()->addDays(2)->toDateString(),
            'description' => 'interés mes pasado',
        ]);

        Sanctum::actingAs($admin->fresh());

        $stats = $this->getJson('/api/pwa/dashboard')
            ->assertStatus(200)
            ->json('stats');

        $this->assertArrayHasKey('interest_month', $stats);
        $this->assertEquals(300000, $stats['interest_month']['amount']);
        // Tendencia: +50% vs mes pasado (300k vs 200k) → good = true.
        $this->assertEquals(50.0, $stats['interest_month']['trend_pct']);
        $this->assertTrue($stats['interest_month']['good']);
    }
}
```

- [ ] **Step 2: Correr el test (rojo)**

Run: `php artisan test --filter=AdminDashboardMonthlyInterestTest`
Expected: FAIL — `interest_month` no existe en el payload todavía. (Si `Income::create` falla por una columna NOT NULL que falte, añadir esa columna al array con un valor válido — p.ej. `created_by` con `$admin->id` — y re-correr; el objetivo del test no cambia.)

- [ ] **Step 3: Implementar en `AdminDashboardMetricsService`**

Añadir el import y el constructor (hoy la clase no tiene constructor):

```php
use App\Services\Metrics\CashFlowMetricsService;
use Carbon\Carbon;
```

```php
    public function __construct(
        private CashFlowMetricsService $cashFlow,
    ) {}
```

Dentro de `getMetrics()`, después de calcular `$interestEarned` (el acumulado, que se conserva), añadir:

```php
        // Utilidad del MES (real, no acumulada): interés ganado en el mes en curso,
        // con tendencia vs el mismo tramo del mes anterior.
        $interestMonth = $this->cashFlow->getInterestEarnedThisMonth($companyId);
        $interestPrevMonth = $this->cashFlow->getInterestEarned(
            $companyId,
            Carbon::now()->subMonthNoOverflow()->startOfMonth(),
            Carbon::now()->subMonthNoOverflow(),
        );
        $interestTrendPct = $interestPrevMonth > 0
            ? round((($interestMonth - $interestPrevMonth) / $interestPrevMonth) * 100, 1)
            : null;
```

Y en el array de retorno, añadir (junto a `interest_earned`, que NO se toca — otros consumidores lo usan):

```php
            'interest_month' => [
                'amount' => round($interestMonth, 2),
                'trend_pct' => $interestTrendPct,
                'good' => $interestTrendPct === null ? null : $interestTrendPct >= 0,
            ],
```

- [ ] **Step 4: Correr el test (verde)**

Run: `php artisan test --filter=AdminDashboardMonthlyInterestTest`
Expected: PASS.

- [ ] **Step 5: Regresión del guard de alcance + Pint**

Run: `php artisan test --filter=DashboardDataScopeTest && ./vendor/bin/pint --dirty`
Expected: 3 tests PASS (admin sigue exponiendo su P&L; supervisor/cobrador sin cambios); Pint limpio.

- [ ] **Step 6: Commit**

```bash
git add app/Services/Dashboard/AdminDashboardMetricsService.php tests/Feature/Pwa/AdminDashboardMonthlyInterestTest.php
git commit -m "feat(pwa): utilidad mensual real + tendencia MoM en el dashboard admin"
```

---

### Task 2: Supervisor — desglose por cobrador + meta de equipo (backend)

Añade `collector_performance` (por cobrador del equipo del supervisor) y `team_goal` al payload supervisor, cableando `getCollectorPerformanceToday()` filtrado al scope de visibilidad ya calculado (`$scopeIds`).

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/DashboardController.php` (método `supervisorDashboard`)
- Test: `tests/Feature/Pwa/SupervisorDashboardBreakdownTest.php`

- [ ] **Step 1: Escribir el test (rojo)**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Company;
use App\Models\CompanyCollectorGoal;
use App\Models\Credit;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use App\Services\PaymentManager;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class SupervisorDashboardBreakdownTest extends TestCase
{
    use DatabaseTransactions;

    #[Test]
    public function supervisor_dashboard_exposes_collector_breakdown_and_team_goal(): void
    {
        foreach (['admin', 'supervisor', 'collector'] as $r) {
            Role::firstOrCreate(['name' => $r, 'guard_name' => 'web']);
        }

        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        $company = Company::factory()->create(['interest_method' => 'flat_rate']);
        Subscription::factory()->active()->create([
            'company_id' => $company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);

        $supervisor = User::factory()->create(['company_id' => $company->id]);
        $supervisor->assignRole('supervisor');

        $collector = User::factory()->create(['company_id' => $company->id]);
        $collector->assignRole('collector');

        // Meta diaria del cobrador: 100.000
        CompanyCollectorGoal::create([
            'company_id' => $company->id,
            'collector_user_id' => $collector->id,
            'daily_goal' => 100000,
        ]);

        // Un crédito de ese cobrador con una cuota, y un pago hoy de 30.000.
        $client = \App\Models\Client::factory()->create(['company_id' => $company->id]);
        $credit = Credit::create([
            'company_id' => $company->id,
            'client_id' => $client->id,
            'created_by_user_id' => $supervisor->id,
            'collector_user_id' => $collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 100000,
            'interest_rate' => 20,
            'installments_count' => 4,
            'periodicity' => 'weekly',
            'start_date' => Carbon::now(),
        ]);
        Installment::create([
            'company_id' => $company->id,
            'credit_id' => $credit->id,
            'installment_number' => 1,
            'status' => Installment::STATUS_PENDING,
            'due_date' => Carbon::now(),
            'principal_amount' => 25000,
            'interest_amount' => 5000,
            'total_amount' => 30000,
            'balance_due' => 30000,
            'amount_paid' => 0,
        ]);
        app(PaymentManager::class)->registerPaymentForCredit(
            $credit->fresh(), 30000, Carbon::now(), $supervisor->id, 'cash',
        );

        Sanctum::actingAs($supervisor->fresh());

        $stats = $this->getJson('/api/pwa/dashboard')
            ->assertStatus(200)
            ->json('stats');

        // Desglose por cobrador presente e incluye a nuestro cobrador con lo cobrado hoy.
        $this->assertArrayHasKey('collector_performance', $stats);
        $row = collect($stats['collector_performance'])->firstWhere('collector_id', $collector->id);
        $this->assertNotNull($row, 'el cobrador del equipo aparece en el desglose');
        $this->assertEquals(30000, $row['collected_today']);
        $this->assertEquals(100000, (float) $row['goal']);

        // Meta de equipo = suma de metas del scope.
        $this->assertArrayHasKey('team_goal', $stats);
        $this->assertEquals(100000, $stats['team_goal']['amount']);
        $this->assertEquals(30000, $stats['team_goal']['collected']);
        $this->assertEquals(30, $stats['team_goal']['percentage']); // 30k / 100k
    }
}
```

- [ ] **Step 2: Correr el test (rojo)**

Run: `php artisan test --filter=SupervisorDashboardBreakdownTest`
Expected: FAIL — `collector_performance`/`team_goal` no existen aún. (Si `registerPaymentForCredit` o algún `create` requiere un campo extra, ajustarlo siguiendo `tests/Feature/Pwa/VoidPaymentTest.php`, que usa el mismo patrón; no cambiar el objetivo del assert.)

- [ ] **Step 3: Implementar en `supervisorDashboard()`**

Antes del `return response()->json([...])`, añadir:

```php
        // Desglose por cobrador (rendimiento del día), filtrado al equipo visible.
        $performance = $this->collectionMetrics->getCollectorPerformanceToday($user->company_id)
            ->when($scopeIds !== null, fn ($c) => $c->whereIn('collector_id', $scopeIds))
            ->values();

        $teamGoal = (float) $performance->sum('goal');
        $teamCollected = (float) $performance->sum('collected_today');
        $teamGoalPct = $teamGoal > 0 ? (int) round(($teamCollected / $teamGoal) * 100) : 0;
```

Y dentro del array `'stats' => [ ... ]`, añadir estas claves (sin quitar las existentes):

```php
                'collector_performance' => $performance->toArray(),
                'team_goal' => [
                    'amount' => round($teamGoal, 2),
                    'collected' => round($teamCollected, 2),
                    'percentage' => $teamGoalPct,
                ],
```

- [ ] **Step 4: Correr el test (verde) + regresión de alcance**

Run: `php artisan test --filter=SupervisorDashboardBreakdownTest`
Expected: PASS.
Run: `php artisan test --filter=DashboardDataScopeTest`
Expected: 3 PASS — **importante**: `collector_performance` y `team_goal` son datos de equipo (no P&L del dueño), así que el guard del supervisor sigue verde. Confirmar que no se agregó ninguna clave prohibida (cash_base/business_total/interest_earned/etc.).

- [ ] **Step 5: Pint + Commit**

```bash
./vendor/bin/pint --dirty
git add app/Http/Controllers/Api/Pwa/DashboardController.php tests/Feature/Pwa/SupervisorDashboardBreakdownTest.php
git commit -m "feat(pwa): desglose por cobrador + meta de equipo en el dashboard supervisor"
```

---

### Task 3: Tendencia de cobros (sparkline + delta hoy) — admin y supervisor (backend)

Añade `collections_trend` (serie de 7 días para el sparkline) y `collected_trend` (delta hoy-vs-ayer) al payload de admin y supervisor, vía `getTeamCollectionTrend()`.

**Files:**
- Modify: `app/Http/Controllers/Api/Pwa/DashboardController.php` (métodos `adminDashboard` y `supervisorDashboard`)
- Test: `tests/Feature/Pwa/DashboardCollectionsTrendTest.php`

- [ ] **Step 1: Escribir el test (rojo)**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Company;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class DashboardCollectionsTrendTest extends TestCase
{
    use DatabaseTransactions;

    private function company(): Company
    {
        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        $company = Company::factory()->create();
        Subscription::factory()->active()->create([
            'company_id' => $company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);

        return $company;
    }

    #[Test]
    public function admin_and_supervisor_dashboards_expose_a_7day_collections_trend(): void
    {
        foreach (['admin', 'supervisor'] as $role) {
            Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
            $company = $this->company();
            $user = User::factory()->create(['company_id' => $company->id]);
            $user->assignRole($role);
            Sanctum::actingAs($user->fresh());

            $stats = $this->getJson('/api/pwa/dashboard')
                ->assertStatus(200)
                ->json('stats');

            $this->assertArrayHasKey('collections_trend', $stats, "{$role} tiene collections_trend");
            $this->assertCount(7, $stats['collections_trend'], 'serie de 7 días');
            $this->assertArrayHasKey('date', $stats['collections_trend'][0]);
            $this->assertArrayHasKey('amount', $stats['collections_trend'][0]);

            $this->assertArrayHasKey('collected_trend', $stats);
            $this->assertArrayHasKey('pct', $stats['collected_trend']);
            $this->assertArrayHasKey('good', $stats['collected_trend']);
        }
    }
}
```

- [ ] **Step 2: Correr el test (rojo)**

Run: `php artisan test --filter=DashboardCollectionsTrendTest`
Expected: FAIL — las claves no existen.

- [ ] **Step 3: Implementar — helper privado + llamadas**

Añadir un helper privado en `DashboardController` (que arma serie + delta a partir del trend):

```php
    /**
     * A partir de la serie de cobros (getTeamCollectionTrend), arma el payload de
     * tendencia: la serie completa (para sparkline) + el delta hoy vs ayer.
     *
     * @param  \Illuminate\Support\Collection<int, array{date:string, amount:float, count:int}>  $trend
     * @return array{series: array<int, array{date:string, amount:float}>, delta: array{pct: float|null, good: bool|null}}
     */
    private function collectionsTrendPayload(\Illuminate\Support\Collection $trend): array
    {
        $amounts = $trend->pluck('amount')->values()->all();
        $n = count($amounts);
        $today = $n > 0 ? (float) $amounts[$n - 1] : 0.0;
        $prev = $n > 1 ? (float) $amounts[$n - 2] : 0.0;
        $pct = $prev > 0 ? round((($today - $prev) / $prev) * 100, 1) : null;

        return [
            'series' => $trend->map(fn ($d) => ['date' => $d['date'], 'amount' => $d['amount']])->all(),
            'delta' => ['pct' => $pct, 'good' => $pct === null ? null : $pct >= 0],
        ];
    }
```

En `adminDashboard()` — antes del `return`, y añadiendo las claves al `'stats'`:

```php
        $trend = $this->collectionMetrics->getTeamCollectionTrend($user->company_id, 7, null);
        $trendPayload = $this->collectionsTrendPayload($trend);
```

Añadir al array `'stats' => $metrics` — como `$metrics` viene del servicio, mezclar tras obtenerlo:

```php
        $metrics = $this->adminMetrics->getMetrics($user->company_id);
        $trend = $this->collectionMetrics->getTeamCollectionTrend($user->company_id, 7, null);
        $trendPayload = $this->collectionsTrendPayload($trend);
        $metrics['collections_trend'] = $trendPayload['series'];
        $metrics['collected_trend'] = $trendPayload['delta'];
```

(el `return` de admin ya hace `'stats' => $metrics`).

En `supervisorDashboard()` — usar el scope del equipo (`$scopeIds`) y añadir al array `'stats'`:

```php
        $trend = $this->collectionMetrics->getTeamCollectionTrend($user->company_id, 7, $scopeIds);
        $trendPayload = $this->collectionsTrendPayload($trend);
```

y dentro de `'stats' => [ ... ]`:

```php
                'collections_trend' => $trendPayload['series'],
                'collected_trend' => $trendPayload['delta'],
```

- [ ] **Step 4: Verde + regresión + Pint**

Run: `php artisan test --filter=DashboardCollectionsTrendTest`
Expected: PASS.
Run: `php artisan test --filter='DashboardDataScopeTest|SupervisorDashboardBreakdown|AdminDashboardMonthlyInterest'`
Expected: todos PASS.
Run: `./vendor/bin/pint --dirty` → limpio.

- [ ] **Step 5: Commit**

```bash
git add app/Http/Controllers/Api/Pwa/DashboardController.php tests/Feature/Pwa/DashboardCollectionsTrendTest.php
git commit -m "feat(pwa): tendencia de cobros 7d (sparkline) + delta hoy vs ayer (admin/supervisor)"
```

---

### Task 4: Componente `Sparkline.vue` + wiring de tendencias en KPIs (frontend)

**Files:**
- Create: `resources/js/pwa/components/dashboard/Sparkline.vue`
- Modify: `resources/js/pwa/components/home/AdminHome.vue`
- Modify: `resources/js/pwa/components/home/SupervisorHome.vue`

- [ ] **Step 1: Crear `Sparkline.vue`**

```vue
<template>
    <svg v-if="points.length > 1" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none"
         class="w-full block" :style="{ height: height + 'px' }" aria-hidden="true">
        <polyline :points="line" fill="none" stroke="currentColor" stroke-width="2"
                  stroke-linecap="round" stroke-linejoin="round" />
    </svg>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
    points: { type: Array, default: () => [] },   // number[]
    width:  { type: Number, default: 300 },
    height: { type: Number, default: 40 },
})

const line = computed(() => {
    const n = props.points.length
    if (n < 2) return ''
    const max = Math.max(...props.points, 1)
    const min = Math.min(...props.points, 0)
    const span = max - min || 1
    const stepX = props.width / (n - 1)
    return props.points.map((v, i) => {
        const x = Math.round(i * stepX)
        const y = Math.round(props.height - ((v - min) / span) * (props.height - 4) - 2)
        return `${x},${y}`
    }).join(' ')
})
</script>
```

- [ ] **Step 2: Wire en `AdminHome.vue`**

Importar y añadir el sparkline a la tarjeta Cartera (bajo el saldo por cobrar), coloreado emerald. En `<script setup>`:

```js
import Sparkline from '../dashboard/Sparkline.vue'

const collectionsTrend = computed(() => (stats.value.collections_trend || []).map(d => d.amount))
```

En el `<template>`, dentro de la tarjeta "Saldo por Cobrar" (la `RouterLink` a `/pwa/delinquency`), justo antes de la barra de composición, añadir:

```vue
<div v-if="collectionsTrend.length > 1" class="mt-2 text-emerald-500 dark:text-emerald-400">
    <Sparkline :points="collectionsTrend" />
    <p class="text-[10px] text-slate-500 mt-0.5">cobros últimos 7 días</p>
</div>
```

Y añadir tendencia al KPI "Utilidad mes" y "Cobrado" — actualizar el computed `kpis` (Task 6 del plan de Fase 1 lo dejó sin `trend`):

```js
const kpis = computed(() => [
    { label: 'Patrimonio',  value: money(stats.value.business_total?.amount), hint: 'caja + por cobrar', accent: true, to: '/pwa/financials', valueClass: 'text-slate-900 dark:text-white' },
    { label: 'Utilidad mes', value: money(stats.value.interest_month?.amount), to: '/pwa/financials', valueClass: 'text-emerald-600 dark:text-emerald-400',
      trend: trendOf(stats.value.interest_month, 'vs mes pasado') },
    { label: 'Caja',        value: money(stats.value.cash_base?.amount),       hint: 'disponible',       to: '/pwa/financials', valueClass: 'text-emerald-600 dark:text-emerald-400' },
    { label: 'Mora',        value: (stats.value.delinquency_rate || 0) + '%',  hint: 'de la cartera',    to: '/pwa/delinquency', valueClass: 'text-slate-900 dark:text-white' },
])

function trendOf(obj, suffix) {
    if (!obj || obj.trend_pct === null || obj.trend_pct === undefined) return null
    const p = obj.trend_pct
    return { dir: p < 0 ? 'down' : 'up', good: obj.good, text: `${p >= 0 ? '+' : ''}${p}% ${suffix}` }
}
```

- [ ] **Step 3: Wire en `SupervisorHome.vue`**

Mismo `import Sparkline` + `collectionsTrend` computed. Añadir el sparkline a la tarjeta "Cartera del equipo". (El desglose por cobrador es la Task 5.)

- [ ] **Step 4: Verificar build**

Run: `npm run build`
Expected: build sin errores.

- [ ] **Step 5: Commit**

```bash
git add resources/js/pwa/components/dashboard/Sparkline.vue resources/js/pwa/components/home/AdminHome.vue resources/js/pwa/components/home/SupervisorHome.vue
git commit -m "feat(pwa): sparkline de cobros + tendencia en KPIs admin/supervisor"
```

---

### Task 5: Supervisor — sección real de desglose por cobrador (frontend)

Reemplaza la `SectionCard` puente de Fase 1 (que solo enlazaba a `/pwa/team`) por la lista real construida desde `stats.collector_performance`, con la meta de equipo arriba.

**Files:**
- Modify: `resources/js/pwa/components/home/SupervisorHome.vue`

- [ ] **Step 1: Reemplazar la sección "Mi equipo"**

En `<script setup>` añadir:

```js
const collectors = computed(() => stats.value.collector_performance || [])
const teamGoal = computed(() => stats.value.team_goal || { amount: 0, collected: 0, percentage: 0 })

function initials(name) {
    return (name || '').split(' ').filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase()
}
function barColor(pct) {
    if (pct >= 100) return 'bg-green-500'
    if (pct >= 50)  return 'bg-emerald-500'
    if (pct > 0)    return 'bg-amber-500'
    return 'bg-slate-400 dark:bg-slate-600'
}
function pctTextColor(pct) {
    if (pct >= 100) return 'text-green-600 dark:text-green-400'
    if (pct >= 50)  return 'text-emerald-600 dark:text-emerald-400'
    if (pct > 0)    return 'text-amber-600 dark:text-amber-400'
    return 'text-slate-500'
}
function money(num) { return '$' + new Intl.NumberFormat('es-CO', { maximumFractionDigits: 0 }).format(num || 0) }
```

Reemplazar la `<SectionCard title="Mi equipo"> ... </SectionCard>` puente por:

```vue
<SectionCard title="Rendimiento por cobrador">
    <template #action>
        <RouterLink to="/pwa/team" class="text-[10px] text-emerald-600 dark:text-emerald-400">ver todos →</RouterLink>
    </template>

    <!-- Meta de equipo -->
    <div v-if="teamGoal.amount > 0" class="mb-3">
        <div class="flex items-baseline justify-between">
            <p class="text-slate-500 text-[10px] font-medium uppercase tracking-wide">Meta de equipo hoy</p>
            <p class="text-[10px] text-slate-500">{{ money(teamGoal.collected) }} / {{ money(teamGoal.amount) }}</p>
        </div>
        <div class="h-2 rounded-full overflow-hidden bg-slate-200 dark:bg-white/6 mt-1.5">
            <div class="h-full rounded-full" :class="barColor(teamGoal.percentage)"
                 :style="{ width: Math.min(teamGoal.percentage, 100) + '%' }"></div>
        </div>
    </div>

    <!-- Empty state -->
    <p v-if="!collectors.length" class="text-slate-500 text-xs py-2 text-center">Sin cobradores en tu equipo.</p>

    <!-- Lista por cobrador -->
    <div v-else class="flex flex-col gap-3">
        <div v-for="c in collectors" :key="c.collector_id" class="flex items-center gap-3">
            <span class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-[11px] font-semibold bg-emerald-500/12 text-emerald-600 dark:text-emerald-400">{{ initials(c.collector_name) }}</span>
            <div class="flex-1 min-w-0">
                <div class="flex justify-between items-baseline">
                    <span class="text-slate-800 dark:text-slate-100 text-xs font-medium truncate">{{ c.collector_name }}</span>
                    <span class="text-slate-500 text-[10px] num">{{ money(c.collected_today) }}<template v-if="c.goal"> / {{ money(c.goal) }}</template></span>
                </div>
                <div class="h-1.5 rounded-full overflow-hidden bg-slate-200 dark:bg-white/6 mt-1.5">
                    <div class="h-full" :class="barColor(c.goal_percentage)" :style="{ width: Math.min(c.goal_percentage, 100) + '%' }"></div>
                </div>
            </div>
            <span class="text-[11px] font-medium w-9 text-right" :class="pctTextColor(c.goal_percentage)">{{ c.goal ? c.goal_percentage + '%' : '—' }}</span>
        </div>
    </div>
</SectionCard>
```

- [ ] **Step 2: Verificar build**

Run: `npm run build`
Expected: build sin errores.

- [ ] **Step 3: Commit**

```bash
git add resources/js/pwa/components/home/SupervisorHome.vue
git commit -m "feat(pwa): sección real de rendimiento por cobrador en el dashboard supervisor"
```

---

### Task 6: CHANGELOG + verificación holística

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: CHANGELOG**

Bajo `## [Sin publicar]`, en `### Añadido`, añadir un bullet: Fase 2 del rediseño de dashboards — utilidad **mensual** real + tendencia MoM (admin), desglose por cobrador + meta de equipo (supervisor), sparkline de cobros 7d + delta hoy-vs-ayer (admin/supervisor); todo cableando servicios existentes (`CashFlowMetricsService`, `CollectionMetricsService`), sin migraciones. Nota: los trends de métricas de *stock* (patrimonio/mora) quedan fuera (requieren snapshots diarios).

- [ ] **Step 2: Suite backend + build + Pint**

Run: `php artisan test --filter='Dashboard|Pwa'`
Expected: verde (los 3 tests nuevos + guard de alcance + PWA existentes; una falla por colisión de la BD de dev compartida se re-verifica aislada — patrón flaky conocido).
Run: `npm run build` → sin errores.
Run: `./vendor/bin/pint --test` → PASS.

- [ ] **Step 3: Verificación visual (preview con dev server)**

- Admin: KPI "Utilidad mes" muestra el monto del **mes** con flecha de tendencia; sparkline de cobros en la tarjeta de cartera.
- Supervisor: sección "Rendimiento por cobrador" con lista real (meta de equipo + barra por cobrador + %); sparkline en cartera del equipo.
- Claro y oscuro OK; alcance por rol intacto (supervisor sin caja/utilidad/gastos).

- [ ] **Step 4: Commit**

```bash
git add CHANGELOG.md
git commit -m "docs(pwa): CHANGELOG Fase 2 del rediseño de dashboards"
```

---

## Self-Review (cobertura vs. spec)

- **Utilidad mensual real (admin):** Task 1 (`interest_month` + trend MoM) + Task 4 (KPI). Corrige el `interest_earned` acumulado mal etiquetado en Fase 1. ✅
- **Desglose por cobrador (supervisor):** Task 2 (backend `collector_performance`, filtrado al scope) + Task 5 (sección real, reemplaza el puente). ✅
- **Meta de equipo (supervisor):** Task 2 (`team_goal`) + Task 5 (barra). ✅
- **Sparkline / tendencia de cobros:** Task 3 (`collections_trend` + `collected_trend`) + Task 4 (`Sparkline.vue`). Se usa cobros (derivable) en vez de saldo de cartera (que necesitaría snapshots). ✅
- **Alcance por rol intacto:** cada Task añade solo claves del alcance del rol; Tasks 2–3 corren `DashboardDataScopeTest` como regresión — `collector_performance`/`team_goal`/`collections_trend` son datos de equipo/empresa del propio rol, no P&L del dueño filtrado hacia abajo. ✅
- **FUERA (documentado):** trends de stock (patrimonio/mora) + sparkline de saldo de cartera → requieren snapshots diarios; su propio plan futuro. ✅

**Consistencia de tipos:** el backend emite `interest_month{amount,trend_pct,good}`, `collector_performance[{collector_id,collector_name,collected_today,goal,goal_percentage,status}]` (forma exacta de `getCollectorPerformanceToday`), `team_goal{amount,collected,percentage}`, `collections_trend[{date,amount}]`, `collected_trend{pct,good}`. El frontend (`trendOf`, `Sparkline points`, la lista de cobradores) consume exactamente esas formas. `KpiCard.trend` espera `{dir,text,good}` — `trendOf()` lo produce. ✅

**Placeholders:** ninguno; las notas "si un `create` requiere un campo extra, ajustar siguiendo VoidPaymentTest" son guías de TDD (test rojo→verde), no huecos de diseño.
