# Citrus — Spec 3: Controle de Efetivo (Consolidado) — 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 a visão administrativa consolidada do efetivo — listagem filtrável (profissional/função/obra/quinzena/data) com totalizador de diárias e export Excel, somente leitura, acessível apenas ao Administrativo.

**Architecture:** `EfetivoSearch` (form de busca) consulta `Efetivo` com `joinWith` em `efetivo_dia`/`obra`/`profissional`/`funcao`, aplica filtros e paginação, e expõe totais (diárias = contagem; profissionais/obras distintos). `ControleEfetivoController` (tela de permissão `controle-efetivo`, só administrativo) renderiza a tabela (desktop) / cards (mobile) com KPIs no topo e exporta o filtro inteiro via `ExportHelper`.

**Tech Stack:** PHP 8.2, Yii2, MySQL 8.0, yii2-bootstrap5, PhpSpreadsheet, Codeception. Banco de teste: `citrus_test`.

**Convenção:** comandos via `docker compose exec php <cmd>` em `citrus/`. Branch `citrus-controle-efetivo` (NÃO mergear na master). Testes contra `citrus_test` (migrar com `docker compose exec -e MYSQL_DATABASE=citrus_test php php yii migrate --interactive=0`).

**⚠️ IMPORTANTE — trabalho não-commitado do desenvolvedor:** há mudanças não-commitadas em
`views/funcao/*`, `views/obra/*`, `views/profissional/*`, `views/layouts/main.php`,
`models/ProfissionalSearch.php`, `commands/SeedController.php`, `web/favicon*`, `PRODUCT.md`,
`dados_iniciais.xlsx`, `views/_pager.php`. **NÃO** dê `git add` em nenhum desses. Cada task commita
**apenas os arquivos que ela cria/altera** (todos novos, exceto nenhum arquivo do dev). Em particular,
**NÃO modifique `main.php`** — o item de menu é entregue na Task 5 como trecho para inserção manual.

---

## Mapa de arquivos (todos novos)

- `migrations/m260619_000001_controle_efetivo_permissao.php` — seed da tela `controle-efetivo`
- `models/EfetivoSearch.php` — busca + `intervaloQuinzena` + totais + lista de quinzenas
- `controllers/ControleEfetivoController.php` — `actionIndex`, `actionExport`
- `views/controle-efetivo/index.php`, `views/controle-efetivo/_search.php`
- `tests/Unit/Models/EfetivoSearchTest.php`
- `tests/functional/ControleEfetivoCest.php`

---

## Task 1: Migration — permissão `controle-efetivo`

**Files:** Create `migrations/m260619_000001_controle_efetivo_permissao.php`

- [ ] **Step 1: Criar a migration**
```php
<?php

use yii\db\Migration;

class m260619_000001_controle_efetivo_permissao extends Migration
{
    public function safeUp()
    {
        $this->insert('{{%permissao}}', [
            'perfil' => 'administrativo',
            'tela' => 'controle-efetivo',
            'acoes' => 'index,export',
        ]);
    }

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

- [ ] **Step 2: Rodar nos dois bancos**
```
docker compose exec php php yii migrate --interactive=0
docker compose exec -e MYSQL_DATABASE=citrus_test php php yii migrate --interactive=0
```
Expected: 1 migration aplicada em cada. Verificar:
`docker compose exec db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" citrus -e "SELECT perfil,tela,acoes FROM permissao WHERE tela=\"controle-efetivo\";"'`
Expected: linha `administrativo | controle-efetivo | index,export`.

- [ ] **Step 3: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/migrations/m260619_000001_controle_efetivo_permissao.php
git commit -m "feat(citrus): permissão controle-efetivo (administrativo)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 2: Helper `intervaloQuinzena` (TDD)

**Files:** Create `models/EfetivoSearch.php` (parcial — só o helper estático nesta task), `tests/Unit/Models/EfetivoSearchTest.php`

- [ ] **Step 1: Teste primeiro `tests/Unit/Models/EfetivoSearchTest.php`**
```php
<?php
namespace app\tests\Unit\Models;

use app\models\EfetivoSearch;

class EfetivoSearchTest extends \Codeception\Test\Unit
{
    public function testIntervaloQuinzenaPrimeira()
    {
        $this->assertEquals(['2026-06-01', '2026-06-15'], EfetivoSearch::intervaloQuinzena('2026-06-1'));
    }

    public function testIntervaloQuinzenaSegundaMes30()
    {
        // junho tem 30 dias
        $this->assertEquals(['2026-06-16', '2026-06-30'], EfetivoSearch::intervaloQuinzena('2026-06-2'));
    }

    public function testIntervaloQuinzenaSegundaMes31()
    {
        // julho tem 31 dias
        $this->assertEquals(['2026-07-16', '2026-07-31'], EfetivoSearch::intervaloQuinzena('2026-07-2'));
    }

    public function testIntervaloQuinzenaFevereiroNaoBissexto()
    {
        $this->assertEquals(['2025-02-16', '2025-02-28'], EfetivoSearch::intervaloQuinzena('2025-02-2'));
    }

    public function testIntervaloQuinzenaFevereiroBissexto()
    {
        $this->assertEquals(['2024-02-16', '2024-02-29'], EfetivoSearch::intervaloQuinzena('2024-02-2'));
    }
}
```

- [ ] **Step 2: Rodar (deve falhar — classe não existe)**
```
docker compose exec php vendor/bin/codecept run Unit Models/EfetivoSearchTest
```
Expected: FAIL ("Class app\models\EfetivoSearch not found").

- [ ] **Step 3: Criar `models/EfetivoSearch.php` com o helper**
```php
<?php

namespace app\models;

use yii\base\Model;

class EfetivoSearch extends Model
{
    public $profissional_id;
    public $funcao_id;
    public $obra_id;
    public $quinzena;
    public $data;

    public function rules()
    {
        return [
            [['profissional_id', 'funcao_id', 'obra_id'], 'integer'],
            [['quinzena', 'data'], 'safe'],
        ];
    }

    /** "AAAA-MM-Q" (Q=1|2) → ['inicio', 'fim'] em Y-m-d (quinzenas fixas 1–15 / 16–último dia). */
    public static function intervaloQuinzena(string $quinzena): array
    {
        [$ano, $mes, $q] = explode('-', $quinzena);
        $mesNum = sprintf('%02d', (int) $mes);
        if ((int) $q === 1) {
            return ["$ano-$mesNum-01", "$ano-$mesNum-15"];
        }
        $ultimoDia = (int) date('t', mktime(0, 0, 0, (int) $mes, 1, (int) $ano));
        return ["$ano-$mesNum-16", "$ano-$mesNum-" . sprintf('%02d', $ultimoDia)];
    }
}
```

- [ ] **Step 4: Rodar (deve passar, 5 testes)**
```
docker compose exec php vendor/bin/codecept run Unit Models/EfetivoSearchTest
```

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/models/EfetivoSearch.php citrus/tests/Unit/Models/EfetivoSearchTest.php
git commit -m "feat(citrus): EfetivoSearch.intervaloQuinzena (quinzenas fixas)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 3: EfetivoSearch — busca, filtros e totais (TDD)

**Files:** Modify `models/EfetivoSearch.php`, `tests/Unit/Models/EfetivoSearchTest.php`

- [ ] **Step 1: Acrescentar testes de busca/total em `EfetivoSearchTest`**
Adicione os métodos abaixo à classe (mantendo os do helper) e o `_fixtures`:
```php
    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 criaLinha(int $obraId, string $data, int $profId): void
    {
        $dia = \app\models\EfetivoDia::findOrCreate($obraId, $data);
        $e = new \app\models\Efetivo([
            'efetivo_dia_id' => $dia->id, 'profissional_id' => $profId, 'hora_entrada' => '08:00',
        ]);
        if (!$e->save()) {
            throw new \RuntimeException(implode(',', $e->getErrorSummary(true)));
        }
    }

    public function testTotalDiariasContaRegistros()
    {
        $this->criaLinha(100, '2026-10-05', 1);
        $this->criaLinha(100, '2026-10-06', 1);
        $s = new EfetivoSearch();
        $dp = $s->search(['EfetivoSearch' => ['quinzena' => '2026-10-1']]);
        $this->assertEquals(2, $dp->getTotalCount());
        $this->assertEquals(2, $s->totalDiarias());
        $this->assertEquals(1, $s->totalProfissionais());
        $this->assertEquals(1, $s->totalObras());
    }

    public function testFiltroPorObraRestringe()
    {
        $this->criaLinha(100, '2026-10-10', 1);
        $this->criaLinha(101, '2026-10-10', 1);
        $s = new EfetivoSearch();
        $s->search(['EfetivoSearch' => ['obra_id' => 100, 'data' => '2026-10-10']]);
        $this->assertEquals(1, $s->totalDiarias());
    }

    public function testDiaSoftDeletadoForaDoTotal()
    {
        $this->criaLinha(100, '2026-10-20', 1);
        $dia = \app\models\EfetivoDia::find()->where(['obra_id' => 100, 'data' => '2026-10-20'])->one();
        $dia->delete(); // soft-delete do dia
        $s = new EfetivoSearch();
        $s->search(['EfetivoSearch' => ['data' => '2026-10-20']]);
        $this->assertEquals(0, $s->totalDiarias());
    }
```

- [ ] **Step 2: Rodar (deve falhar — search/totalDiarias não existem)**
```
docker compose exec php vendor/bin/codecept run Unit Models/EfetivoSearchTest
```

- [ ] **Step 3: Implementar busca/totais em `models/EfetivoSearch.php`**
Acrescente (imports no topo: `use yii\data\ActiveDataProvider; use yii\db\ActiveQuery;`):
```php
    private function baseQuery(): ActiveQuery
    {
        return Efetivo::find()
            ->joinWith(['efetivoDia', 'efetivoDia.obra', 'profissional', 'profissional.funcao'])
            ->andWhere(['efetivo_dia.deleted_at' => null]);
    }

    private function aplicarFiltros(ActiveQuery $q): ActiveQuery
    {
        $q->andFilterWhere(['efetivo.profissional_id' => $this->profissional_id])
          ->andFilterWhere(['profissional.funcao_id' => $this->funcao_id])
          ->andFilterWhere(['efetivo_dia.obra_id' => $this->obra_id]);
        if (!empty($this->quinzena)) {
            [$ini, $fim] = self::intervaloQuinzena($this->quinzena);
            $q->andWhere(['between', 'efetivo_dia.data', $ini, $fim]);
        }
        $q->andFilterWhere(['efetivo_dia.data' => $this->data]);
        return $q;
    }

    public function search(array $params): ActiveDataProvider
    {
        $this->load($params);
        $this->validate();
        $query = $this->aplicarFiltros($this->baseQuery())
            ->orderBy(['efetivo_dia.data' => SORT_DESC, 'efetivo.id' => SORT_DESC]);
        return new ActiveDataProvider([
            'query' => $query,
            'pagination' => ['pageSize' => 50],
            'sort' => false,
        ]);
    }

    /** Lista (sem paginação) para export. Requer load() prévio (ou via search()). */
    public function todos(): array
    {
        return $this->aplicarFiltros($this->baseQuery())
            ->orderBy(['efetivo_dia.data' => SORT_DESC, 'efetivo.id' => SORT_DESC])->all();
    }

    public function totalDiarias(): int
    {
        return (int) $this->aplicarFiltros($this->baseQuery())->count('DISTINCT efetivo.id');
    }

    public function totalProfissionais(): int
    {
        return (int) $this->aplicarFiltros($this->baseQuery())->count('DISTINCT efetivo.profissional_id');
    }

    public function totalObras(): int
    {
        return (int) $this->aplicarFiltros($this->baseQuery())->count('DISTINCT efetivo_dia.obra_id');
    }

    /** Opções do select de quinzena: últimos 12 meses × 2 quinzenas. Retorna [valor => rótulo]. */
    public function quinzenasDisponiveis(): array
    {
        $meses = ['', 'Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
        $out = [];
        $base = strtotime(date('Y-m-01'));
        for ($i = 0; $i < 12; $i++) {
            $t = strtotime("-$i month", $base);
            $y = (int) date('Y', $t);
            $m = (int) date('n', $t);
            $out["$y-$m-2"] = "2ª quinzena · {$meses[$m]}/$y";
            $out["$y-$m-1"] = "1ª quinzena · {$meses[$m]}/$y";
        }
        return $out;
    }
```

- [ ] **Step 4: Rodar (deve passar)**
```
docker compose exec php vendor/bin/codecept run Unit Models/EfetivoSearchTest
docker compose exec php vendor/bin/codecept run
```
Expected: EfetivoSearchTest verde (8 testes); suíte geral verde. (Os testes criam linhas dentro do fixture-transaction; `count('DISTINCT efetivo.id')` evita inflar com os joins.)

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/models/EfetivoSearch.php citrus/tests/Unit/Models/EfetivoSearchTest.php
git commit -m "feat(citrus): EfetivoSearch busca/filtros/totais (diárias, profissionais, obras)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 4: ControleEfetivoController + views (index)

**Files:** Create `controllers/ControleEfetivoController.php`, `views/controle-efetivo/index.php`, `views/controle-efetivo/_search.php`; Test `tests/functional/ControleEfetivoCest.php`

- [ ] **Step 1: Controller**
`controllers/ControleEfetivoController.php`:
```php
<?php

declare(strict_types=1);

namespace app\controllers;

use Yii;
use app\models\EfetivoSearch;
use app\models\Profissional;
use app\models\Funcao;
use app\models\Obra;
use app\components\PermissaoBehavior;
use yii\web\Controller;
use yii\helpers\ArrayHelper;

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

    public function actionIndex()
    {
        $searchModel = new EfetivoSearch();
        $dataProvider = $searchModel->search(Yii::$app->request->queryParams);

        return $this->render('index', [
            'searchModel' => $searchModel,
            'dataProvider' => $dataProvider,
            'totalDiarias' => $dataProvider->getTotalCount(),
            'totalProfissionais' => $searchModel->totalProfissionais(),
            'totalObras' => $searchModel->totalObras(),
            'optProfissionais' => ArrayHelper::map(
                Profissional::find()->where(['status' => 'ativo'])->orderBy('nome')->all(), 'id', 'nome'),
            'optFuncoes' => ArrayHelper::map(Funcao::ativas(), 'id', 'nome'),
            'optObras' => ArrayHelper::map(
                Obra::find()->where(['status' => 'ativo'])->orderBy('nome')->all(), 'id', 'nome'),
            'optQuinzenas' => $searchModel->quinzenasDisponiveis(),
        ]);
    }
}
```
(A action `export` é adicionada na Task 5.)

- [ ] **Step 2: `views/controle-efetivo/_search.php`**
```php
<?php
/** @var app\models\EfetivoSearch $model */
/** @var array $optProfissionais */
/** @var array $optFuncoes */
/** @var array $optObras */
/** @var array $optQuinzenas */
use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;
$form = ActiveForm::begin([
    'method' => 'get', 'action' => ['index'],
    'options' => ['class' => 'filter-form'],
    'fieldConfig' => ['options' => ['class' => 'filter-field']],
]);
echo $form->field($model, 'profissional_id')->dropDownList($optProfissionais, ['prompt' => 'Profissional: todos']);
echo $form->field($model, 'funcao_id')->dropDownList($optFuncoes, ['prompt' => 'Função: todas']);
echo $form->field($model, 'obra_id')->dropDownList($optObras, ['prompt' => 'Obra: todas']);
echo $form->field($model, 'quinzena')->dropDownList($optQuinzenas, ['prompt' => 'Quinzena: todas']);
echo $form->field($model, 'data')->input('date');
echo Html::submitButton('Filtrar', ['class' => 'btn-soft']);
ActiveForm::end();
```

- [ ] **Step 3: `views/controle-efetivo/index.php`**
```php
<?php
/** @var yii\web\View $this */
/** @var app\models\EfetivoSearch $searchModel */
/** @var yii\data\ActiveDataProvider $dataProvider */
/** @var int $totalDiarias */
/** @var int $totalProfissionais */
/** @var int $totalObras */
/** @var array $optProfissionais */
/** @var array $optFuncoes */
/** @var array $optObras */
/** @var array $optQuinzenas */

use yii\helpers\Html;
use yii\helpers\Url;

$this->title = 'Controle de Efetivo';
$models = $dataProvider->getModels();
$hm = fn($t) => $t ? substr((string) $t, 0, 5) : '—';
?>
<div class="kpi-grid">
    <div class="kpi"><div class="v"><?= $totalDiarias ?></div><div class="l">Diárias</div></div>
    <div class="kpi"><div class="v"><?= $totalProfissionais ?></div><div class="l">Profissionais</div></div>
    <div class="kpi"><div class="v"><?= $totalObras ?></div><div class="l">Obras</div></div>
</div>

<div class="list-toolbar">
    <button type="button" class="filter-toggle"
        onclick="this.setAttribute('aria-expanded', this.closest('.list-toolbar').classList.toggle('filters-open'));">
        <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M7 12h10M10 18h4"/></svg>
        Filtros
    </button>
    <div class="filter-bar"><?= $this->render('_search', [
        'model' => $searchModel, 'optProfissionais' => $optProfissionais, 'optFuncoes' => $optFuncoes,
        'optObras' => $optObras, 'optQuinzenas' => $optQuinzenas,
    ]) ?></div>
    <div class="list-actions">
        <?= Html::a('Excel', ['export'] + Yii::$app->request->queryParams, ['class' => 'btn-ghost']) ?>
    </div>
</div>

<div class="only-desktop">
    <table class="data-table">
        <thead><tr><th>Data</th><th>Obra</th><th>Profissional / Função</th><th>Entrada</th><th>Saída</th><th>Observação</th></tr></thead>
        <tbody>
        <?php foreach ($models as $e): /** @var app\models\Efetivo $e */ ?>
            <tr>
                <td><?= Yii::$app->formatter->asDate($e->efetivoDia->data) ?></td>
                <td class="muted"><?= Html::encode($e->efetivoDia->obra->nome ?? '—') ?></td>
                <td><?= Html::encode($e->profissional->nome ?? '—') ?><br>
                    <span class="muted"><?= Html::encode($e->profissional->funcao->nome ?? '') ?></span></td>
                <td><?= $hm($e->hora_entrada) ?></td>
                <td><?= $hm($e->hora_saida) ?></td>
                <td class="muted"><?= Html::encode((string) $e->observacao) ?></td>
            </tr>
        <?php endforeach; ?>
        <?php if (!$models): ?>
            <tr><td colspan="6" class="table-empty">Nenhum registro para o filtro.</td></tr>
        <?php endif; ?>
        </tbody>
    </table>
    <?= \yii\widgets\LinkPager::widget(['pagination' => $dataProvider->pagination]) ?>
</div>

<div class="only-mobile">
    <div class="card-list">
        <?php foreach ($models as $e): ?>
            <div class="entity-card" style="display:block">
                <h3><?= Html::encode($e->profissional->nome ?? '—') ?></h3>
                <div class="meta">
                    <?= Yii::$app->formatter->asDate($e->efetivoDia->data) ?> ·
                    <?= Html::encode($e->efetivoDia->obra->nome ?? '—') ?> ·
                    <?= $hm($e->hora_entrada) ?>→<?= $hm($e->hora_saida) ?>
                </div>
                <?php if ($e->observacao): ?><div class="meta"><?= Html::encode($e->observacao) ?></div><?php endif; ?>
            </div>
        <?php endforeach; ?>
        <?php if (!$models): ?><p class="em-breve">Nenhum registro para o filtro.</p><?php endif; ?>
    </div>
    <?= \yii\widgets\LinkPager::widget(['pagination' => $dataProvider->pagination]) ?>
</div>
```

- [ ] **Step 4: Functional test `tests/functional/ControleEfetivoCest.php`**
```php
<?php

namespace app\tests\functional;

use app\tests\Support\FunctionalTester;

class ControleEfetivoCest
{
    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 adminAcessa(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(1)); // administrativo
        $I->amOnRoute('controle-efetivo/index');
        $I->seeResponseCodeIs(200);
        $I->see('Controle de Efetivo');
        $I->see('Diárias');
    }

    public function liderRecebe403(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(2)); // líder (sem permissão controle-efetivo)
        $I->amOnRoute('controle-efetivo/index');
        $I->seeResponseCodeIs(403);
    }

    public function filtroPorObraReduz(FunctionalTester $I)
    {
        // cria 2 linhas em obras diferentes no mesmo dia
        $d1 = \app\models\EfetivoDia::findOrCreate(100, '2026-11-03');
        $d2 = \app\models\EfetivoDia::findOrCreate(101, '2026-11-03');
        (new \app\models\Efetivo(['efetivo_dia_id' => $d1->id, 'profissional_id' => 1, 'hora_entrada' => '08:00']))->save();
        (new \app\models\Efetivo(['efetivo_dia_id' => $d2->id, 'profissional_id' => 1, 'hora_entrada' => '08:00']))->save();

        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
        $I->amOnRoute('controle-efetivo/index', ['EfetivoSearch' => ['obra_id' => 100, 'data' => '2026-11-03']]);
        $I->seeResponseCodeIs(200);
        $I->see('Edifício Aurora'); // obra 100
        $I->dontSee('Obra Sem Líder'); // obra 101 filtrada fora
    }
}
```

- [ ] **Step 5: Rodar + suíte**
```
docker compose exec php vendor/bin/codecept run functional ControleEfetivoCest
docker compose exec php vendor/bin/codecept run
```
Expected: 3 cenários verdes; suíte geral verde.

- [ ] **Step 6: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/controllers/ControleEfetivoController.php citrus/views/controle-efetivo citrus/tests/functional/ControleEfetivoCest.php
git commit -m "feat(citrus): controle de efetivo (listagem filtrável + totalizador)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 5: Export Excel + trecho do menu

**Files:** Modify `controllers/ControleEfetivoController.php`, `tests/functional/ControleEfetivoCest.php`

- [ ] **Step 1: Adicionar `actionExport` ao controller** (mesmo filtro, sem paginação)
```php
    public function actionExport()
    {
        $searchModel = new EfetivoSearch();
        $searchModel->load(Yii::$app->request->queryParams);
        $rows = [];
        foreach ($searchModel->todos() as $e) {
            $rows[] = [
                Yii::$app->formatter->asDate($e->efetivoDia->data),
                $e->efetivoDia->obra->nome ?? '',
                $e->profissional->nome ?? '',
                $e->profissional->funcao->nome ?? '',
                $e->hora_entrada ? substr((string) $e->hora_entrada, 0, 5) : '',
                $e->hora_saida ? substr((string) $e->hora_saida, 0, 5) : '',
                (string) $e->observacao,
            ];
        }
        return \app\components\ExportHelper::download('controle-efetivo',
            ['Data', 'Obra', 'Profissional', 'Função', 'Entrada', 'Saída', 'Observação'], $rows);
    }
```
Add `use app\components\ExportHelper;` to the top (or use FQCN as above).

- [ ] **Step 2: Teste do export — acrescentar a `ControleEfetivoCest`**
```php
    public function exportaXlsx(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
        $I->amOnRoute('controle-efetivo/export');
        $I->seeResponseCodeIs(200);
        $ct = $I->grabHttpHeader('Content-Type');
        $I->assertStringContainsString('spreadsheetml', (string) $ct);
    }
```
> Se `grabHttpHeader` não estiver disponível no módulo funcional, verifique via `Yii::$app->response->headers->get('Content-Type')` num teste alternativo, ou apenas `seeResponseCodeIs(200)` + confirme que a action não lança. O essencial: admin consegue exportar (200, content-type xlsx).

- [ ] **Step 3: Rodar + suíte**
```
docker compose exec php vendor/bin/codecept run functional ControleEfetivoCest
docker compose exec php vendor/bin/codecept run
```
Expected: 4 cenários verdes; suíte geral verde.

- [ ] **Step 4: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/controllers/ControleEfetivoController.php citrus/tests/functional/ControleEfetivoCest.php
git commit -m "feat(citrus): export Excel do controle de efetivo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

- [ ] **Step 5: Entregar o trecho do menu (NÃO commitar, NÃO editar main.php)**
O `main.php` tem alterações não-commitadas do desenvolvedor. **Não edite o arquivo.** Apenas relate, no resumo final, o trecho para o desenvolvedor inserir no array `$mais` (ou equivalente) do `views/layouts/main.php`, no padrão dele:
```php
if ($pode('controle-efetivo')) { $mais[] = ['label' => 'Controle de Efetivo', 'ctrl' => 'controle-efetivo', 'url' => ['controle-efetivo/index'], 'icon' => 'efetivo']; }
```
(Onde `$pode('controle-efetivo')` segue o mesmo helper de permissão já usado no layout. Ajustar o nome do ícone conforme o conjunto disponível.)

---

## Task 6: Verificação final

**Files:** nenhum novo.

- [ ] **Step 1: Suíte + app**
```
docker compose exec php vendor/bin/codecept run
docker compose ps
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082/login
```
Expected: suíte verde; containers Up; login 200.

- [ ] **Step 2: Confirmar isolamento e que nada do dev foi tocado**
```
cd /Users/jhordangabriel/Documents/projects && git status --short citrus
```
Expected: os arquivos não-commitados do desenvolvedor (views/funcao, views/obra, views/profissional, main.php, ProfissionalSearch, SeedController, favicons, PRODUCT.md, dados_iniciais.xlsx, _pager.php) continuam lá, intactos; nenhum deles aparece nos commits do Spec 3.

- [ ] **Step 3: Smoke manual**
Logar como administrativo, abrir Controle de Efetivo (via URL `/controle-efetivo` ou pelo menu se já inserido): conferir totalizador (Diárias/Profissionais/Obras), aplicar filtros (obra, quinzena, data), exportar Excel; testar mobile (cards + toggle de filtros) e desktop (tabela). Confirmar que um líder recebe 403.

---

## Notas de fechamento

- **1 registro = 1 diária** (totalizador = contagem; profissionais/obras são distintos).
- **Só Administrativo** acessa (tela `controle-efetivo`); líder/gerente recebem 403.
- **main.php não foi tocado** (trabalho não-commitado do dev) — inserir o item de menu manualmente (Task 5, Step 5).
- Branch `citrus-controle-efetivo`; não mergear na master.
- Próximo (Spec 4): fechamento de card quinzenal (agrupa diárias por obra a partir destes registros).
