# Citrus Spec 5 — Dashboard 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:** Construir o dashboard de gestão — 4 KPIs, gráfico de barras (efetivo/dia), gráfico de rosca (diárias por obra), painel com 6 alertas e badge — como home pós-login para Administrativo/Gerente.

**Architecture:** Dois serviços de leitura sem estado (`DashboardService` para KPIs/gráficos, `AlertaService` para as 6 regras + total), um `DashboardController` gateado por `PermissaoBehavior`, view Layout A com Chart.js (CDN) lendo dados JSON embutidos, migration que semeia a permissão e ajuste no `SiteController` para redirecionar a home. Sem novas tabelas de negócio.

**Tech Stack:** PHP 8.2 + Yii2 + MySQL 8 + Chart.js (CDN) + Codeception.

**Convenções fixas deste plano:**
- Tudo roda no container: prefixo `docker compose exec php`.
- Migrations aplicam em DEV e TESTE:
  - DEV: `docker compose exec php php yii migrate --interactive=0`
  - TESTE: `docker compose exec -e MYSQL_DATABASE=citrus_test php php yii migrate --interactive=0`
- Testes: `docker compose exec php vendor/bin/codecept run Unit` / `... run Functional`.
- Branch: `citrus-dashboard` (já criada). NUNCA mergear na master.
- **NÃO** tocar/commitar arquivos não-commitados do desenvolvedor: `main.php`, `views/funcao|obra|profissional`, `models/Profissional.php`, `models/ProfissionalSearch.php`, `config/web.php`, `config/secrets.example.php`, `commands/SeedController.php`, favicons, `PRODUCT.md`, `dados_iniciais.xlsx`, `views/_pager.php`. `web/css/app.css` é compartilhado (carrega mudanças do dev) — só **acrescente** estilos ao final, nunca reescreva.
- Cada task commita SOMENTE os arquivos que cria/edita (listados na própria task).

**Fatos do código (já verificados — use exatamente assim):**
- `Permissao::permite(Profissional $user, string $tela, string $acao): bool`. `permissao` tem colunas `perfil`, `tela`, `acoes` (CSV de ações).
- `PermissaoBehavior`: `tela` = id do controller; `acao` = id da action (ou `acaoMap`). Guest → `loginRequired()`; logado sem permissão → 403.
- `Profissional::temPerfil(string $perfil): bool`; perfis na coluna CSV `perfis`; `status` ∈ {ativo,inativo,afastado}; FK `funcao_id`.
- `Obra` tem `lider1_id`, `lider2_id`, `status`.
- `Efetivo` belongsTo `EfetivoDia` via relação `efetivoDia` (`efetivo_dia_id`); campos `profissional_id`, `hora_entrada`, `hora_saida` (string `HH:MM`, saida pode ser null), `observacao`. `EfetivoDia` tem `obra_id`, `data` (`Y-m-d`), `deleted_at`. **Soft-delete do join:** sempre filtrar `->andWhere(['efetivo_dia.deleted_at' => null])` (padrão já usado em `CardController::actionIndex`).
- `EfetivoDia::findOrCreate(int $obraId, string $data, ?int $liderId = null): self`.
- `Card`: consts `STATUS_RASCUNHO`/`STATUS_FECHADO`/`STATUS_PAGO`; campo `quinzena` no formato `AAAA-M-Q` (mês sem zero à esquerda, Q∈{1,2}).
- `Quinzena::intervalo(string $quinzena): array` → `[iniY-m-d, fimY-m-d]`; `Quinzena::rotulo(string): string`.
- Testes Unit em `tests/Unit/<Pasta>/`, namespace `app\tests\Unit\<Pasta>`; classe estende `\Codeception\Test\Unit`, fixtures via `_fixtures()`. Functional em `tests/functional/`, namespace `app\tests\functional`, usa `FunctionalTester`.
- Fixtures disponíveis: `FuncaoFixture`, `ProfissionalFixture` (admin id=1 perfis `administrativo`; lider id=2 perfis `profissional,lider`, é `lider1` da obra 100), `ObraFixture` (100 Edifício Aurora ativo; 101 sem líder ativo), `EfetivoDiaFixture`, `EfetivoFixture`.

---

### Task 1: Migration — semear permissão da tela `dashboard`

**Files:**
- Create: `migrations/m260619_000005_seed_dashboard_permissao.php`

- [ ] **Step 1: Criar a migration**

```php
<?php

use yii\db\Migration;

/**
 * Semeia a permissão da tela `dashboard` (ações index,view) para
 * os perfis administrativo e gerente.
 */
class m260619_000005_seed_dashboard_permissao extends Migration
{
    public function safeUp()
    {
        foreach (['administrativo', 'gerente'] as $perfil) {
            $this->insert('{{%permissao}}', [
                'perfil' => $perfil,
                'tela' => 'dashboard',
                'acoes' => 'index,view',
            ]);
        }
    }

    public function safeDown()
    {
        $this->delete('{{%permissao}}', ['tela' => 'dashboard']);
    }
}
```

- [ ] **Step 2: Aplicar no DEV**

Run: `docker compose exec php php yii migrate --interactive=0`
Expected: `1 migration was applied` (m260619_000005_seed_dashboard_permissao).

- [ ] **Step 3: Aplicar no TESTE**

Run: `docker compose exec -e MYSQL_DATABASE=citrus_test php php yii migrate --interactive=0`
Expected: aplica a mesma migration em `citrus_test`.

- [ ] **Step 4: Conferir**

Run: `docker compose exec -T db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" citrus -N -e "SELECT perfil,tela,acoes FROM permissao WHERE tela=\"dashboard\";"'`
Expected: duas linhas — `administrativo dashboard index,view` e `gerente dashboard index,view`.

- [ ] **Step 5: Commit**

```bash
git add migrations/m260619_000005_seed_dashboard_permissao.php
git commit -m "feat(citrus): permissão da tela dashboard (administrativo+gerente)"
```

---

### Task 2: DashboardService — KPIs (TDD)

**Files:**
- Create: `components/DashboardService.php`
- Test: `tests/Unit/Services/DashboardServiceTest.php`

- [ ] **Step 1: Escrever o teste que falha**

Create `tests/Unit/Services/DashboardServiceTest.php`:

```php
<?php

namespace app\tests\Unit\Services;

use app\components\DashboardService;
use app\models\Card;
use app\models\Efetivo;
use app\models\EfetivoDia;

class DashboardServiceTest extends \Codeception\Test\Unit
{
    public function _fixtures(): array
    {
        return [
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
            'obra' => \app\tests\fixtures\ObraFixture::class,
            'efetivo_dia' => \app\tests\fixtures\EfetivoDiaFixture::class,
            'efetivo' => \app\tests\fixtures\EfetivoFixture::class,
        ];
    }

    private function efetivo(int $obraId, string $data, int $profId): void
    {
        $dia = EfetivoDia::findOrCreate($obraId, $data);
        (new Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => $profId, 'hora_entrada' => '08:00']))->save();
    }

    public function testKpisBasicos()
    {
        $svc = new DashboardService('2027-08-20');
        // ObraFixture: 100 e 101 ativas; ProfissionalFixture: admin(1) e lider(2) ativos.
        $this->assertEquals(2, $svc->obrasAtivas());
        $this->assertEquals(2, $svc->profissionaisAtivos());
    }

    public function testDiariasNoMesContaSomenteOMesDaReferencia()
    {
        $this->efetivo(100, '2027-08-05', 2);
        $this->efetivo(100, '2027-08-06', 2);
        $this->efetivo(100, '2027-07-30', 2); // fora do mês
        $svc = new DashboardService('2027-08-20');
        $this->assertEquals(2, $svc->diariasNoMes());
    }

    public function testCardsPendentesNaQuinzenaCorrente()
    {
        $this->assertEquals('2027-8-2', (new DashboardService('2027-08-20'))->quinzenaAtual());
        (new Card(['profissional_id' => 2, 'quinzena' => '2027-8-2', 'status' => Card::STATUS_RASCUNHO]))->save(false);
        (new Card(['profissional_id' => 1, 'quinzena' => '2027-8-2', 'status' => Card::STATUS_FECHADO]))->save(false);
        $svc = new DashboardService('2027-08-20');
        $this->assertEquals(1, $svc->cardsPendentes());
    }
}
```

- [ ] **Step 2: Rodar e ver falhar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/DashboardServiceTest`
Expected: FAIL — `Class "app\components\DashboardService" not found`.

- [ ] **Step 3: Implementar o serviço (KPIs)**

Create `components/DashboardService.php`:

```php
<?php

declare(strict_types=1);

namespace app\components;

use app\models\Obra;
use app\models\Profissional;
use app\models\Efetivo;
use app\models\Card;

/**
 * Agregações de leitura para o dashboard. Recebe a data de referência
 * (default "hoje") para ser determinístico em testes.
 */
class DashboardService
{
    private string $hoje;

    public function __construct(?string $ref = null)
    {
        $this->hoje = $ref ?? date('Y-m-d');
    }

    public function obrasAtivas(): int
    {
        return (int) Obra::find()->where(['status' => 'ativo'])->count();
    }

    public function profissionaisAtivos(): int
    {
        return (int) Profissional::find()->where(['status' => 'ativo'])->count();
    }

    public function diariasNoMes(): int
    {
        $ini = date('Y-m-01', strtotime($this->hoje));
        $fim = date('Y-m-t', strtotime($this->hoje));
        return (int) Efetivo::find()->joinWith('efetivoDia')
            ->where(['between', 'efetivo_dia.data', $ini, $fim])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->count();
    }

    public function quinzenaAtual(): string
    {
        $t = strtotime($this->hoje);
        $q = (int) date('j', $t) <= 15 ? 1 : 2;
        return sprintf('%d-%d-%d', (int) date('Y', $t), (int) date('n', $t), $q);
    }

    public function cardsPendentes(): int
    {
        return (int) Card::find()
            ->where(['quinzena' => $this->quinzenaAtual(), 'status' => Card::STATUS_RASCUNHO])
            ->count();
    }
}
```

- [ ] **Step 4: Rodar e ver passar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/DashboardServiceTest`
Expected: OK (3 testes).

- [ ] **Step 5: Commit**

```bash
git add components/DashboardService.php tests/Unit/Services/DashboardServiceTest.php
git commit -m "feat(citrus): DashboardService KPIs (obras/profissionais/diárias/cards)"
```

---

### Task 2 nota
A pasta `tests/Unit/Services/` é nova; o autoload PSR-4 já cobre `app\tests\` (ver `codeception.yml` namespace `app\tests`), então nenhum ajuste de config é necessário.

---

### Task 3: DashboardService — datasets dos gráficos (TDD)

**Files:**
- Modify: `components/DashboardService.php`
- Test: `tests/Unit/Services/DashboardServiceTest.php` (adicionar métodos)

- [ ] **Step 1: Adicionar testes que falham**

Adicione ao final da classe `DashboardServiceTest` (antes da chave de fechamento):

```php
    public function testEfetivoPorDiaPreencheDiasVaziosComZero()
    {
        $this->efetivo(100, '2027-08-20', 2); // hoje
        $this->efetivo(100, '2027-08-18', 2);
        $svc = new DashboardService('2027-08-20');
        $g = $svc->efetivoPorDia(30);
        $this->assertCount(30, $g['labels']);
        $this->assertCount(30, $g['valores']);
        $this->assertEquals('20/08', end($g['labels']));   // último = hoje
        $this->assertEquals(1, $g['valores'][29]);          // 1 registro hoje
        $this->assertEquals(0, $g['valores'][28]);          // 19/08 sem registro
        $this->assertEquals(1, $g['valores'][27]);          // 18/08 com registro
    }

    public function testDistribuicaoPorObraSomaCem()
    {
        $this->efetivo(100, '2027-08-05', 2);
        $this->efetivo(100, '2027-08-06', 2);
        $this->efetivo(100, '2027-08-07', 2);
        $this->efetivo(101, '2027-08-05', 2); // 3 x obra100, 1 x obra101
        $svc = new DashboardService('2027-08-20');
        $d = $svc->distribuicaoPorObra();
        $this->assertCount(2, $d);
        $this->assertEquals('Edifício Aurora', $d[0]['obra']); // maior primeiro
        $this->assertEquals(3, $d[0]['qtd']);
        $this->assertEqualsWithDelta(75.0, $d[0]['pct'], 0.05);
        $this->assertEqualsWithDelta(100.0, $d[0]['pct'] + $d[1]['pct'], 0.01);
    }
```

- [ ] **Step 2: Rodar e ver falhar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/DashboardServiceTest`
Expected: FAIL — `Call to undefined method ...::efetivoPorDia()`.

- [ ] **Step 3: Implementar os métodos de gráfico**

Adicione a `DashboardService` (antes da chave final da classe):

```php
    /** @return array{labels:string[],valores:int[]} barras: registros por dia nos últimos $dias. */
    public function efetivoPorDia(int $dias = 30): array
    {
        $inicio = date('Y-m-d', strtotime($this->hoje . ' -' . ($dias - 1) . ' days'));
        $rows = Efetivo::find()->joinWith('efetivoDia')
            ->where(['between', 'efetivo_dia.data', $inicio, $this->hoje])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->select(['efetivo_dia.data AS d', 'COUNT(*) AS c'])
            ->groupBy('efetivo_dia.data')
            ->asArray()->all();

        $mapa = [];
        foreach ($rows as $r) {
            $mapa[$r['d']] = (int) $r['c'];
        }

        $labels = [];
        $valores = [];
        for ($i = 0; $i < $dias; $i++) {
            $d = date('Y-m-d', strtotime($inicio . " +$i days"));
            $labels[] = date('d/m', strtotime($d));
            $valores[] = $mapa[$d] ?? 0;
        }
        return ['labels' => $labels, 'valores' => $valores];
    }

    /** @return list<array{obra:string,qtd:int,pct:float}> rosca: % de diárias por obra no mês (maior primeiro, soma 100). */
    public function distribuicaoPorObra(): array
    {
        $ini = date('Y-m-01', strtotime($this->hoje));
        $fim = date('Y-m-t', strtotime($this->hoje));
        $rows = Efetivo::find()->joinWith('efetivoDia')
            ->where(['between', 'efetivo_dia.data', $ini, $fim])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->select(['efetivo_dia.obra_id AS obra_id', 'COUNT(*) AS qtd'])
            ->groupBy('efetivo_dia.obra_id')
            ->asArray()->all();

        $total = 0;
        foreach ($rows as $r) {
            $total += (int) $r['qtd'];
        }
        if ($total === 0) {
            return [];
        }

        $nomes = Obra::find()->select(['nome', 'id'])->indexBy('id')->column();
        $itens = [];
        foreach ($rows as $r) {
            $qtd = (int) $r['qtd'];
            $itens[] = [
                'obra' => $nomes[$r['obra_id']] ?? ('Obra #' . $r['obra_id']),
                'qtd' => $qtd,
                'pct' => round($qtd / $total * 100, 1),
            ];
        }
        usort($itens, fn($a, $b) => $b['qtd'] <=> $a['qtd']);

        // joga a sobra de arredondamento na maior fatia (primeira)
        $soma = 0.0;
        foreach ($itens as $it) {
            $soma += $it['pct'];
        }
        $itens[0]['pct'] = round($itens[0]['pct'] + (100 - $soma), 1);

        return $itens;
    }
```

- [ ] **Step 4: Rodar e ver passar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/DashboardServiceTest`
Expected: OK (5 testes).

- [ ] **Step 5: Commit**

```bash
git add components/DashboardService.php tests/Unit/Services/DashboardServiceTest.php
git commit -m "feat(citrus): DashboardService gráficos (efetivo/dia, distribuição por obra)"
```

---

### Task 4: AlertaService — dias úteis + regras 1-3 (TDD)

**Files:**
- Create: `components/AlertaService.php`
- Test: `tests/Unit/Services/AlertaServiceTest.php`

- [ ] **Step 1: Escrever o teste que falha**

Create `tests/Unit/Services/AlertaServiceTest.php`:

```php
<?php

namespace app\tests\Unit\Services;

use app\components\AlertaService;
use app\models\Efetivo;
use app\models\EfetivoDia;
use Yii;

class AlertaServiceTest extends \Codeception\Test\Unit
{
    public function _fixtures(): array
    {
        return [
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
            'obra' => \app\tests\fixtures\ObraFixture::class,
            'efetivo_dia' => \app\tests\fixtures\EfetivoDiaFixture::class,
            'efetivo' => \app\tests\fixtures\EfetivoFixture::class,
        ];
    }

    private function efetivo(int $obraId, string $data, int $profId, ?string $saida = '17:00'): void
    {
        $dia = EfetivoDia::findOrCreate($obraId, $data);
        (new Efetivo([
            'efetivo_dia_id' => $dia->id, 'profissional_id' => $profId,
            'hora_entrada' => '08:00', 'hora_saida' => $saida,
        ]))->save();
    }

    private function tipos(AlertaService $svc): array
    {
        return array_column($svc->todos(), 'tipo');
    }

    public function testDiasUteisAtrasPulaFimDeSemana()
    {
        // 2027-08-23 é uma segunda-feira. 1 dia útil atrás = sexta 2027-08-20.
        $svc = new AlertaService('2027-08-23');
        $this->assertEquals('2027-08-20', $svc->dataDiasUteisAtras(1));
        // 3 dias úteis atrás de segunda = quarta 2027-08-18.
        $this->assertEquals('2027-08-18', $svc->dataDiasUteisAtras(3));
    }

    public function testObraSemEfetivoDispara()
    {
        // Nenhuma obra tem efetivo recente → ambas (100 e 101) entram.
        $svc = new AlertaService('2027-08-23');
        $this->assertContains('obra_sem_efetivo', $this->tipos($svc));
    }

    public function testObraSemEfetivoNaoDisparaQuandoHaRegistroRecente()
    {
        // Cobre as duas obras ativas (100 e 101) dentro dos últimos 3 dias úteis.
        $this->efetivo(100, '2027-08-21', 2);
        $this->efetivo(101, '2027-08-21', 2);
        $svc = new AlertaService('2027-08-23');
        $this->assertNotContains('obra_sem_efetivo', $this->tipos($svc));
    }

    public function testProfissionalSemFuncaoDispara()
    {
        Yii::$app->db->createCommand()->insert('profissional', [
            'nome' => 'Sem Função', 'cpf' => '123.456.789-00', 'data_nascimento' => '1990-01-01',
            'endereco' => 'Rua X', 'whatsapp' => '(11) 90000-1234', 'funcao_id' => null,
            'valor_diaria' => 0, 'perfis' => 'profissional', 'status' => 'ativo',
            'created_at' => 1700000000, 'updated_at' => 1700000000, 'deleted_at' => null,
        ])->execute();
        $svc = new AlertaService('2027-08-23');
        $this->assertContains('profissional_sem_funcao', $this->tipos($svc));
    }

    public function testProfissionalSemRegistroDispara()
    {
        // Sem nenhum efetivo nos últimos 5 dias úteis → profissionais ativos entram.
        $svc = new AlertaService('2027-08-23');
        $this->assertContains('profissional_sem_registro', $this->tipos($svc));
    }
}
```

- [ ] **Step 2: Rodar e ver falhar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/AlertaServiceTest`
Expected: FAIL — `Class "app\components\AlertaService" not found`.

- [ ] **Step 3: Implementar o serviço (helper + regras 1-3 + todos/total parciais)**

Create `components/AlertaService.php`:

```php
<?php

declare(strict_types=1);

namespace app\components;

use app\models\Obra;
use app\models\Profissional;
use app\models\Efetivo;

/**
 * Avalia as anomalias do sistema (§7.2). Cada regra devolve null (sem casos)
 * ou um alerta com tipo/severidade/titulo/mensagem/itens.
 */
class AlertaService
{
    public const CRITICO = 'critico';
    public const ATENCAO = 'atencao';

    private string $hoje;

    public function __construct(?string $ref = null)
    {
        $this->hoje = $ref ?? date('Y-m-d');
    }

    /** Recua $n dias úteis (pula sábado/domingo) a partir de hoje. Sem feriados na v1. */
    public function dataDiasUteisAtras(int $n): string
    {
        $t = strtotime($this->hoje);
        $contados = 0;
        while ($contados < $n) {
            $t = strtotime('-1 day', $t);
            if ((int) date('N', $t) < 6) { // 1..5 = seg..sex
                $contados++;
            }
        }
        return date('Y-m-d', $t);
    }

    /** @return list<array{tipo:string,severidade:string,titulo:string,mensagem:string,itens:string[]}> */
    public function todos(): array
    {
        $alertas = [
            $this->obraSemEfetivo(),
            $this->profissionalSemFuncao(),
            $this->profissionalSemRegistro(),
        ];
        return array_values(array_filter($alertas));
    }

    public function total(): int
    {
        return count($this->todos());
    }

    private function obraSemEfetivo(): ?array
    {
        $corte = $this->dataDiasUteisAtras(3);
        $itens = [];
        foreach (Obra::find()->where(['status' => 'ativo'])->all() as $obra) {
            $tem = Efetivo::find()->joinWith('efetivoDia')
                ->andWhere(['efetivo_dia.obra_id' => $obra->id])
                ->andWhere(['>=', 'efetivo_dia.data', $corte])
                ->andWhere(['efetivo_dia.deleted_at' => null])
                ->exists();
            if (!$tem) {
                $itens[] = $obra->nome;
            }
        }
        if (!$itens) {
            return null;
        }
        return [
            'tipo' => 'obra_sem_efetivo', 'severidade' => self::ATENCAO,
            'titulo' => 'Obra sem efetivo',
            'mensagem' => count($itens) . ' obra(s) sem registro de efetivo nos últimos 3 dias úteis.',
            'itens' => $itens,
        ];
    }

    private function profissionalSemFuncao(): ?array
    {
        $itens = Profissional::find()
            ->where(['status' => 'ativo', 'funcao_id' => null])
            ->select('nome')->column();
        if (!$itens) {
            return null;
        }
        return [
            'tipo' => 'profissional_sem_funcao', 'severidade' => self::ATENCAO,
            'titulo' => 'Profissional sem função',
            'mensagem' => count($itens) . ' profissional(is) ativo(s) sem função vinculada.',
            'itens' => $itens,
        ];
    }

    private function profissionalSemRegistro(): ?array
    {
        $corte = $this->dataDiasUteisAtras(5);
        $itens = [];
        foreach (Profissional::find()->where(['status' => 'ativo'])->all() as $prof) {
            $tem = Efetivo::find()->joinWith('efetivoDia')
                ->andWhere(['efetivo.profissional_id' => $prof->id])
                ->andWhere(['>=', 'efetivo_dia.data', $corte])
                ->andWhere(['efetivo_dia.deleted_at' => null])
                ->exists();
            if (!$tem) {
                $itens[] = $prof->nome;
            }
        }
        if (!$itens) {
            return null;
        }
        return [
            'tipo' => 'profissional_sem_registro', 'severidade' => self::ATENCAO,
            'titulo' => 'Profissional sem registro',
            'mensagem' => count($itens) . ' profissional(is) ativo(s) sem efetivo nos últimos 5 dias úteis.',
            'itens' => $itens,
        ];
    }
}
```

- [ ] **Step 4: Rodar e ver passar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/AlertaServiceTest`
Expected: OK (5 testes).

- [ ] **Step 5: Commit**

```bash
git add components/AlertaService.php tests/Unit/Services/AlertaServiceTest.php
git commit -m "feat(citrus): AlertaService dias úteis + regras obra/função/registro"
```

---

### Task 5: AlertaService — regras 4-6 + montagem final (TDD)

**Files:**
- Modify: `components/AlertaService.php`
- Test: `tests/Unit/Services/AlertaServiceTest.php` (adicionar métodos)

- [ ] **Step 1: Adicionar testes que falham**

Adicione ao final da classe `AlertaServiceTest`:

```php
    public function testCardRascunhoQuinzenaEncerradaDispara()
    {
        // Quinzena 2027-7-2 (16–31/07) já encerrou antes de 2027-08-23.
        (new \app\models\Card([
            'profissional_id' => 2, 'quinzena' => '2027-7-2', 'status' => \app\models\Card::STATUS_RASCUNHO,
        ]))->save(false);
        $svc = new AlertaService('2027-08-23');
        $this->assertContains('card_rascunho_quinzena_encerrada', $this->tipos($svc));
    }

    public function testCardRascunhoQuinzenaCorrenteNaoDispara()
    {
        // Quinzena 2027-8-2 (16–31/08) ainda não encerrou em 2027-08-23.
        (new \app\models\Card([
            'profissional_id' => 2, 'quinzena' => '2027-8-2', 'status' => \app\models\Card::STATUS_RASCUNHO,
        ]))->save(false);
        $svc = new AlertaService('2027-08-23');
        $this->assertNotContains('card_rascunho_quinzena_encerrada', $this->tipos($svc));
    }

    public function testRegistroSemSaidaDispara()
    {
        // Registro de ontem (2027-08-22) sem hora de saída → +24h.
        $this->efetivo(100, '2027-08-22', 2, null);
        $svc = new AlertaService('2027-08-23');
        $this->assertContains('registro_sem_saida', $this->tipos($svc));
    }

    public function testRegistroSemSaidaDeHojeNaoDispara()
    {
        $this->efetivo(100, '2027-08-23', 2, null); // hoje, ainda não passou 24h
        $svc = new AlertaService('2027-08-23');
        $this->assertNotContains('registro_sem_saida', $this->tipos($svc));
    }

    public function testLiderSemObraDispara()
    {
        // Cria um líder não vinculado a nenhuma obra (o lider id=2 da fixture é lider1 da obra 100).
        Yii::$app->db->createCommand()->insert('profissional', [
            'nome' => 'Líder Solto', 'cpf' => '321.654.987-00', 'data_nascimento' => '1985-05-05',
            'endereco' => 'Rua Y', 'whatsapp' => '(11) 91111-2222', 'funcao_id' => 10,
            'valor_diaria' => 0, 'perfis' => 'lider', 'status' => 'ativo',
            'created_at' => 1700000000, 'updated_at' => 1700000000, 'deleted_at' => null,
        ])->execute();
        $svc = new AlertaService('2027-08-23');
        $tipos = $this->tipos($svc);
        $this->assertContains('lider_sem_obra', $tipos);
    }

    public function testTotalContaAlertasAtivos()
    {
        $svc = new AlertaService('2027-08-23');
        $this->assertEquals(count($svc->todos()), $svc->total());
    }
```

- [ ] **Step 2: Rodar e ver falhar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/AlertaServiceTest`
Expected: FAIL — os novos testes não encontram os tipos `card_rascunho_quinzena_encerrada` etc. (ainda não montados em `todos()`).

- [ ] **Step 3: Implementar regras 4-6 e incluí-las em `todos()`**

Em `AlertaService`, adicione os `use` no topo:

```php
use app\models\Card;
use app\models\Quinzena;
```

Substitua o corpo de `todos()` por:

```php
    public function todos(): array
    {
        $alertas = [
            $this->obraSemEfetivo(),
            $this->profissionalSemFuncao(),
            $this->profissionalSemRegistro(),
            $this->cardRascunhoQuinzenaEncerrada(),
            $this->registroSemSaida(),
            $this->liderSemObra(),
        ];
        return array_values(array_filter($alertas));
    }
```

Adicione os três métodos privados (antes da chave final da classe):

```php
    private function cardRascunhoQuinzenaEncerrada(): ?array
    {
        $itens = [];
        $cards = Card::find()->where(['status' => Card::STATUS_RASCUNHO])->all();
        foreach ($cards as $card) {
            [, $fim] = Quinzena::intervalo($card->quinzena);
            if ($fim < $this->hoje) { // intervalo terminou antes de hoje
                $nome = $card->profissional->nome ?? ('Card #' . $card->id);
                $itens[] = $nome . ' — ' . Quinzena::rotulo($card->quinzena);
            }
        }
        if (!$itens) {
            return null;
        }
        return [
            'tipo' => 'card_rascunho_quinzena_encerrada', 'severidade' => self::CRITICO,
            'titulo' => 'Card em rascunho após o fechamento da quinzena',
            'mensagem' => count($itens) . ' card(s) em rascunho de quinzena já encerrada.',
            'itens' => $itens,
        ];
    }

    private function registroSemSaida(): ?array
    {
        $rows = Efetivo::find()->joinWith('efetivoDia')
            ->andWhere(['efetivo.hora_saida' => null])
            ->andWhere(['<', 'efetivo_dia.data', $this->hoje]) // dia anterior ou antes = +24h
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->all();
        if (!$rows) {
            return null;
        }
        $itens = [];
        foreach ($rows as $e) {
            $itens[] = ($e->profissional->nome ?? ('Prof #' . $e->profissional_id))
                . ' — ' . Yii::$app->formatter->asDate($e->efetivoDia->data);
        }
        return [
            'tipo' => 'registro_sem_saida', 'severidade' => self::CRITICO,
            'titulo' => 'Registro sem hora de saída',
            'mensagem' => count($itens) . ' registro(s) com entrada e sem saída há mais de 24h.',
            'itens' => $itens,
        ];
    }

    private function liderSemObra(): ?array
    {
        $itens = [];
        foreach (Profissional::find()->where(['status' => 'ativo'])->all() as $prof) {
            if (!$prof->temPerfil('lider')) {
                continue;
            }
            $vinculado = Obra::find()->where(['status' => 'ativo'])
                ->andWhere(['or', ['lider1_id' => $prof->id], ['lider2_id' => $prof->id]])
                ->exists();
            if (!$vinculado) {
                $itens[] = $prof->nome;
            }
        }
        if (!$itens) {
            return null;
        }
        return [
            'tipo' => 'lider_sem_obra', 'severidade' => self::ATENCAO,
            'titulo' => 'Líder sem obra vinculada',
            'mensagem' => count($itens) . ' líder(es) sem nenhuma obra associada.',
            'itens' => $itens,
        ];
    }
```

Adicione `use Yii;` no topo (usado em `registroSemSaida`).

- [ ] **Step 4: Rodar e ver passar**

Run: `docker compose exec php vendor/bin/codecept run Unit Services/AlertaServiceTest`
Expected: OK (11 testes).

- [ ] **Step 5: Commit**

```bash
git add components/AlertaService.php tests/Unit/Services/AlertaServiceTest.php
git commit -m "feat(citrus): AlertaService regras card/sem-saída/líder + total"
```

---

### Task 6: DashboardController + view + permissão (functional 200/403)

**Files:**
- Create: `controllers/DashboardController.php`
- Create: `views/dashboard/index.php`
- Create: `views/dashboard/_kpis.php`
- Create: `views/dashboard/_alertas.php`
- Test: `tests/functional/DashboardCest.php`

- [ ] **Step 1: Escrever o teste functional que falha**

Create `tests/functional/DashboardCest.php`:

```php
<?php

namespace app\tests\functional;

use app\tests\Support\FunctionalTester;

class DashboardCest
{
    public function _before(FunctionalTester $I)
    {
        $I->haveFixtures([
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
            'obra' => \app\tests\fixtures\ObraFixture::class,
            'efetivo_dia' => \app\tests\fixtures\EfetivoDiaFixture::class,
            'efetivo' => \app\tests\fixtures\EfetivoFixture::class,
        ]);
    }

    public function adminVeDashboard(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(1)); // administrativo
        $I->amOnRoute('dashboard/index');
        $I->seeResponseCodeIs(200);
        $I->see('Obras ativas');
        $I->see('Profissionais ativos');
        $I->see('Cards pendentes');
    }

    public function naoAutorizadoRecebe403(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(2)); // profissional,lider — sem tela dashboard
        $I->amOnRoute('dashboard/index');
        $I->seeResponseCodeIs(403);
    }
}
```

- [ ] **Step 2: Rodar e ver falhar**

Run: `docker compose exec php vendor/bin/codecept run Functional DashboardCest`
Expected: FAIL — rota `dashboard/index` inexistente (404/erro).

- [ ] **Step 3: Criar o controller**

Create `controllers/DashboardController.php`:

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use app\components\PermissaoBehavior;
use app\components\DashboardService;
use app\components\AlertaService;
use yii\web\Controller;

class DashboardController extends Controller
{
    public function behaviors(): array
    {
        return [
            'access' => ['class' => PermissaoBehavior::class],
        ];
    }

    public function actionIndex(): string
    {
        $dash = new DashboardService();
        $alertas = new AlertaService();
        return $this->render('index', [
            'kpis' => [
                'obrasAtivas' => $dash->obrasAtivas(),
                'profissionaisAtivos' => $dash->profissionaisAtivos(),
                'diariasNoMes' => $dash->diariasNoMes(),
                'cardsPendentes' => $dash->cardsPendentes(),
            ],
            'efetivoPorDia' => $dash->efetivoPorDia(30),
            'distribuicaoPorObra' => $dash->distribuicaoPorObra(),
            'alertas' => $alertas->todos(),
        ]);
    }
}
```

- [ ] **Step 4: Criar as views (estrutura; gráficos entram na Task 7)**

Create `views/dashboard/_kpis.php`:

```php
<?php
/** @var array $kpis */
use yii\helpers\Html;
$cards = [
    ['Obras ativas', $kpis['obrasAtivas'], false],
    ['Profissionais ativos', $kpis['profissionaisAtivos'], false],
    ['Diárias no mês', $kpis['diariasNoMes'], false],
    ['Cards pendentes', $kpis['cardsPendentes'], $kpis['cardsPendentes'] > 0],
];
?>
<div class="dash-kpis">
    <?php foreach ($cards as [$label, $valor, $hot]): ?>
        <div class="dash-kpi<?= $hot ? ' hot' : '' ?>">
            <div class="n"><?= (int) $valor ?></div>
            <div class="l"><?= Html::encode($label) ?></div>
        </div>
    <?php endforeach; ?>
</div>
```

Create `views/dashboard/_alertas.php`:

```php
<?php
/** @var array $alertas */
use yii\helpers\Html;
?>
<div class="dash-alertas">
    <h3 class="efetivo-lista-titulo">⚠ Alertas (<?= count($alertas) ?>)</h3>
    <?php if (!$alertas): ?>
        <p class="muted">Nenhuma anomalia detectada.</p>
    <?php else: ?>
        <?php foreach ($alertas as $a): ?>
            <div class="dash-alerta">
                <span class="dot <?= $a['severidade'] === 'critico' ? 'critico' : 'atencao' ?>"></span>
                <div>
                    <strong><?= Html::encode($a['titulo']) ?></strong>
                    <div class="muted"><?= Html::encode($a['mensagem']) ?></div>
                    <?php if (!empty($a['itens'])): ?>
                        <div class="dash-alerta-itens"><?= Html::encode(implode(' · ', array_slice($a['itens'], 0, 8))) ?><?= count($a['itens']) > 8 ? ' …' : '' ?></div>
                    <?php endif; ?>
                </div>
            </div>
        <?php endforeach; ?>
    <?php endif; ?>
</div>
```

Create `views/dashboard/index.php`:

```php
<?php
/** @var yii\web\View $this */
/** @var array $kpis */
/** @var array $efetivoPorDia */
/** @var array $distribuicaoPorObra */
/** @var array $alertas */
$this->title = 'Dashboard';
?>
<div class="dashboard">
    <?= $this->render('_kpis', ['kpis' => $kpis]) ?>

    <div class="dash-graficos">
        <div class="dash-card dash-grafico-barras">
            <div class="dash-card-titulo">Efetivo por dia (30 dias)</div>
            <canvas id="grafico-efetivo"
                    data-labels='<?= json_encode($efetivoPorDia['labels']) ?>'
                    data-valores='<?= json_encode($efetivoPorDia['valores']) ?>'></canvas>
        </div>
        <div class="dash-card dash-grafico-rosca">
            <div class="dash-card-titulo">Diárias por obra (mês)</div>
            <canvas id="grafico-obras"
                    data-dist='<?= json_encode($distribuicaoPorObra) ?>'></canvas>
        </div>
    </div>

    <?= $this->render('_alertas', ['alertas' => $alertas]) ?>
</div>
```

- [ ] **Step 5: Rodar e ver passar**

Run: `docker compose exec php vendor/bin/codecept run Functional DashboardCest`
Expected: OK (2 testes) — admin 200 vê os KPIs, líder 403.

- [ ] **Step 6: Commit**

```bash
git add controllers/DashboardController.php views/dashboard/index.php views/dashboard/_kpis.php views/dashboard/_alertas.php tests/functional/DashboardCest.php
git commit -m "feat(citrus): DashboardController + view (KPIs e alertas) com permissão"
```

---

### Task 7: Gráficos (Chart.js via CDN) + CSS Layout A

**Files:**
- Create: `web/js/dashboard.js`
- Modify: `views/dashboard/index.php` (registrar Chart.js + dashboard.js)
- Modify: `web/css/app.css` (APENAS acrescentar ao final)

- [ ] **Step 1: Criar o JS dos gráficos**

Create `web/js/dashboard.js`:

```js
(function () {
  if (typeof Chart === 'undefined') return;

  var LIME = '#C0F024', INK = '#0C0C0C';
  var PALETA = ['#C0F024', '#9CCC6C', '#0C0C0C', '#6b8f2f', '#d7e98a', '#3f7d00', '#b9b9b9'];

  var elB = document.getElementById('grafico-efetivo');
  if (elB) {
    new Chart(elB, {
      type: 'bar',
      data: {
        labels: JSON.parse(elB.dataset.labels || '[]'),
        datasets: [{ data: JSON.parse(elB.dataset.valores || '[]'), backgroundColor: LIME, borderRadius: 3 }]
      },
      options: {
        responsive: true, maintainAspectRatio: false,
        plugins: { legend: { display: false } },
        scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
      }
    });
  }

  var elD = document.getElementById('grafico-obras');
  if (elD) {
    var dist = JSON.parse(elD.dataset.dist || '[]');
    new Chart(elD, {
      type: 'doughnut',
      data: {
        labels: dist.map(function (d) { return d.obra + ' (' + d.pct + '%)'; }),
        datasets: [{ data: dist.map(function (d) { return d.qtd; }), backgroundColor: PALETA, borderColor: '#fff', borderWidth: 2 }]
      },
      options: {
        responsive: true, maintainAspectRatio: false,
        plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } } }
      }
    });
  }
})();
```

- [ ] **Step 2: Registrar Chart.js (CDN) e o dashboard.js na view**

Em `views/dashboard/index.php`, logo após o bloco de docblock/`$this->title`, adicione:

```php
<?php
$this->registerJsFile('https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js', ['position' => \yii\web\View::POS_END]);
$this->registerJsFile('@web/js/dashboard.js', ['depends' => [\app\assets\AppAsset::class], 'position' => \yii\web\View::POS_END]);
?>
```

- [ ] **Step 3: Acrescentar o CSS do dashboard (ao FINAL de `web/css/app.css`)**

```css

/* ===== Dashboard (Spec 5) ===== */
.dashboard{display:flex;flex-direction:column;gap:18px}
.dash-kpis{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.dash-kpi{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}
.dash-kpi .n{font-size:28px;font-weight:800;color:var(--ink);line-height:1}
.dash-kpi .l{font-size:11px;color:var(--gray-500);text-transform:uppercase;letter-spacing:.4px;margin-top:6px}
.dash-kpi.hot{background:#fffdf3;border-color:#f0e6c0}
.dash-kpi.hot .n{color:#8a6d00}

.dash-graficos{display:flex;flex-direction:column;gap:12px}
.dash-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px}
.dash-card-titulo{font-size:11px;color:var(--gray-500);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px}
.dash-card canvas{width:100%!important;height:240px!important}

.dash-alertas{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px}
.dash-alerta{display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-top:1px solid var(--border)}
.dash-alerta:first-of-type{border-top:0}
.dash-alerta .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;margin-top:5px}
.dash-alerta .dot.critico{background:#e5484d}
.dash-alerta .dot.atencao{background:#e8a200}
.dash-alerta-itens{font-size:12px;color:var(--gray-500);margin-top:3px}

@media (min-width:768px){
  .dash-kpis{grid-template-columns:repeat(4,1fr)}
  .dash-graficos{flex-direction:row}
  .dash-grafico-barras{flex:2;min-width:0}
  .dash-grafico-rosca{flex:1;min-width:0}
}
```

- [ ] **Step 4: Verificar que a suíte segue verde e o canvas é renderizado**

Run: `docker compose exec php vendor/bin/codecept run Functional DashboardCest`
Expected: OK (2 testes) — a presença do CDN/JS não quebra o functional (o Yii2 functional não executa JS, mas a página renderiza os `<canvas>` com os data-attributes).

- [ ] **Step 5: Commit**

```bash
git add web/js/dashboard.js views/dashboard/index.php web/css/app.css
git commit -m "feat(citrus): gráficos do dashboard (Chart.js) + layout A (CSS)"
```

---

### Task 8: Home pós-login (SiteController) + testes de redirect/conteúdo

**Files:**
- Modify: `controllers/SiteController.php` (`actionIndex`)
- Test: `tests/functional/DashboardCest.php` (adicionar métodos)

- [ ] **Step 1: Adicionar testes que falham**

Adicione à classe `DashboardCest`:

```php
    public function homeRedirecionaLogadoComPermissao(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(1)); // administrativo
        $I->amOnRoute('site/index');
        $I->seeResponseCodeIs(200);
        $I->see('Obras ativas'); // caiu no dashboard
    }

    public function dashboardMostraAlertaECanvas(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
        $I->amOnRoute('dashboard/index');
        $I->seeElement('canvas#grafico-efetivo');
        $I->seeElement('canvas#grafico-obras');
        // Sem efetivo recente nas fixtures → alerta de obra sem efetivo aparece.
        $I->see('Obra sem efetivo');
    }
```

- [ ] **Step 2: Rodar e ver falhar**

Run: `docker compose exec php vendor/bin/codecept run Functional DashboardCest`
Expected: FAIL — `homeRedirecionaLogadoComPermissao` não vê "Obras ativas" (a home ainda renderiza o `index` atual).

- [ ] **Step 3: Ajustar `SiteController::actionIndex`**

Substitua o método `actionIndex` em `controllers/SiteController.php` por:

```php
    public function actionIndex()
    {
        $user = Yii::$app->user;
        if (!$user->isGuest && \app\models\Permissao::permite($user->identity, 'dashboard', 'index')) {
            return $this->redirect(['dashboard/index']);
        }
        return $this->render('index', [
            'profissionaisAtivos' => Profissional::find()->where(['status' => 'ativo'])->count(),
            'obrasAtivas' => \app\models\Obra::find()->where(['status' => 'ativo'])->count(),
        ]);
    }
```

Confirme que `use Yii;` já existe no topo de `SiteController.php` (existe — `Yii::$app` já é usado em outras actions). O `actionIndex` passa a declarar retorno misto, então remova o type-hint `: string` da assinatura (como já feito acima).

- [ ] **Step 4: Rodar e ver passar**

Run: `docker compose exec php vendor/bin/codecept run Functional DashboardCest`
Expected: OK (4 testes).

- [ ] **Step 5: Commit**

```bash
git add controllers/SiteController.php tests/functional/DashboardCest.php
git commit -m "feat(citrus): dashboard como home pós-login (redirect com permissão)"
```

---

### Task 9: Verificação final + trechos de menu/badge

**Files:** nenhum novo (verificação + entrega de snippets).

- [ ] **Step 1: Suíte completa**

Run: `docker compose exec php vendor/bin/codecept run`
Expected: tudo verde (os 68 testes anteriores + os novos de Dashboard).

- [ ] **Step 2: Lint dos arquivos PHP criados**

Run:
```bash
docker compose exec php php -l components/DashboardService.php
docker compose exec php php -l components/AlertaService.php
docker compose exec php php -l controllers/DashboardController.php
docker compose exec php php -l controllers/SiteController.php
```
Expected: `No syntax errors detected` em todos.

- [ ] **Step 3: Confirmar que nenhum arquivo do dev foi commitado**

Run: `git log --oneline citrus-card..citrus-dashboard` e, para cada commit, `git show --stat <sha>`.
Expected: apenas os arquivos listados nas tasks (migration, `components/*`, `controllers/DashboardController.php`, `controllers/SiteController.php`, `views/dashboard/*`, `web/js/dashboard.js`, `web/css/app.css`, `tests/...`, `docs/...`). `main.php` e demais arquivos do dev permanecem como `git status --short` os mostrava (não-commitados).

- [ ] **Step 4: Smoke manual (opcional, navegador)**

Logar como `admin` em `http://localhost:8082` → deve cair direto no dashboard; conferir 4 KPIs, gráfico de barras, rosca e painel de alertas.

- [ ] **Step 5: Entregar os trechos para o desenvolvedor colar no `main.php`**

O dashboard precisa do item de menu e do badge de alertas — `main.php` é do desenvolvedor, então estes trechos **não são commitados**, apenas entregues:

Item de menu (seguindo o helper `$item`/`$pode` usado nos demais itens):
```php
$pode('dashboard') ? $item('Dashboard', 'dashboard', ['dashboard/index'], 'speedometer') : null,
```

Badge de alertas (total de anomalias ativas) — calcular uma vez no topo do layout e exibir ao lado do item:
```php
// topo do main.php (área PHP), só para quem tem a tela dashboard:
use app\components\AlertaService;
$totalAlertas = (!Yii::$app->user->isGuest
    && \app\models\Permissao::permite(Yii::$app->user->identity, 'dashboard', 'index'))
    ? (new AlertaService())->total() : 0;
```
```html
<!-- ao renderizar o item "Dashboard", anexar o badge quando houver alertas: -->
<?= $totalAlertas > 0 ? '<span class="menu-badge">' . $totalAlertas . '</span>' : '' ?>
```

- [ ] **Step 6: Relatar conclusão**

Reportar: suíte verde (total de testes), arquivos do dev intactos, e entregar os trechos acima. NÃO mergear na master.

---

## Self-Review (preenchido pelo autor do plano)

**1. Cobertura do spec:**
- §1 KPIs → Task 2. §3 gráficos → Task 3. §4 (6 alertas + dias úteis + total) → Tasks 4-5. §5 permissão → Task 1; home → Task 8; menu/badge → Task 9 Step 5. §6 layout A → Tasks 6-7. §7 testes Unit/Functional → Tasks 2-8. §8 arquivos → todos cobertos.
- Sem lacunas.

**2. Placeholders:** nenhum "TBD/TODO"; todo passo de código tem código completo.

**3. Consistência de tipos:** `DashboardService` métodos (`obrasAtivas/profissionaisAtivos/diariasNoMes/quinzenaAtual/cardsPendentes/efetivoPorDia/distribuicaoPorObra`) usados igual no controller (Task 6). `AlertaService::todos()/total()/dataDiasUteisAtras()` consistentes entre Tasks 4-5-9. Chaves do array de alerta (`tipo/severidade/titulo/mensagem/itens`) idênticas entre serviço (Tasks 4-5), view `_alertas.php` (Task 6) e testes. Data-attributes do canvas (`data-labels/data-valores/data-dist`) batem entre view (Task 6) e `dashboard.js` (Task 7).
