# Citrus — Spec 1: Fundação + Cadastros — 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:** Levantar a base do sistema Citrus (scaffold Yii2 + Docker, app-shell mobile tema claro/lime, favicon, autenticação com identidade `Profissional`, transversais de soft-delete + auditoria + export Excel) e o módulo de Cadastros (Funções, Profissionais, Obras).

**Architecture:** App Yii2 `app-basic` rodando em Docker (nginx + php-fpm + mysql), espelhando as convenções do projeto irmão `london`. Entidades de negócio herdam de `BaseActiveRecord` (timestamps + auditoria automática + soft-delete via `SoftDeleteQuery`). Identidade do sistema é o próprio `Profissional` (login/senha em colunas), com múltiplos perfis (`SET`) e bloqueio por tentativas. UI é um shell mobile (bottom tab bar + FAB) com CSS próprio sobre Bootstrap5.

**Tech Stack:** PHP 8.2, Yii2 (`~2.0.54`), `yii2-bootstrap5`, MySQL 8.0, Docker, PhpSpreadsheet, Codeception, PHPStan, PHPCS.

**Convenção de trabalho:** todos os comandos PHP/yii/composer/testes rodam **dentro do container php** via `docker compose exec php <cmd>`. Caminhos são relativos a `citrus/`.

---

## Mapa de arquivos

**Infra / config**
- `.docker/php/Dockerfile`, `.docker/nginx/default.conf`, `docker-compose.yml`, `.env`, `.env.example`
- `composer.json`, `config/web.php`, `config/db.php`, `config/console.php`, `config/params.php`, `config/test.php`, `config/test_db.php`

**Transversais (`models/`, `components/`)**
- `models/SoftDeleteQuery.php` — ActiveQuery que filtra `deleted_at IS NULL`
- `components/AuditBehavior.php` — grava em `auditoria`
- `models/Auditoria.php` — model do log
- `models/BaseActiveRecord.php` — timestamps + audit + soft-delete
- `components/ExportHelper.php` — exporta listagens para `.xlsx`

**Domínio (`models/`)**
- `models/Funcao.php`, `models/Profissional.php`, `models/Obra.php`, `models/Permissao.php`
- `models/LoginForm.php`
- `models/*Search.php` (filtros de listagem) para Funcao/Profissional/Obra

**Controllers / views**
- `controllers/SiteController.php` (+ login/logout, dashboard stub, error, captcha)
- `controllers/FuncaoController.php`, `controllers/ProfissionalController.php`, `controllers/ObraController.php`
- `controllers/EfetivoController.php`, `controllers/CardController.php`, `controllers/RelatorioController.php` (stubs "em breve")
- `views/layouts/main.php` (shell), `views/site/login.php`, `views/site/index.php`, `views/site/em-breve.php`
- `views/funcao/{index,_form,_search}.php`, `views/profissional/{index,_form,_search}.php`, `views/obra/{index,_form,_search}.php`

**Assets**
- `assets/AppAsset.php`, `web/css/app.css`, `web/js/mask.js`, `web/js/cep.js`, `web/js/profissional-form.js`
- `web/img/logo.png`, favicons em `web/` + `web/site.webmanifest`

**Migrations (`migrations/`)**
- create_auditoria, create_funcao, create_profissional, create_obra, create_permissao (+ seed)

**Testes (`tests/`)**
- `tests/Unit/*` e `tests/functional/*` + `tests/fixtures/*`

---

## Task 1: Scaffold Yii2 + Docker + subir containers

**Files:**
- Create: `composer.json`, `.docker/php/Dockerfile`, `.docker/nginx/default.conf`, `docker-compose.yml`, `.env`, `.env.example`, `config/db.php`, `config/params.php`
- (scaffold também cria `config/web.php`, `config/console.php`, `web/index.php`, `yii`, etc.)

- [ ] **Step 1: Criar o projeto base via Composer**

Rode na pasta `projects/` (cria dentro de uma pasta temporária e move o conteúdo para `citrus/`, preservando os arquivos já existentes — `logo.png`, `isologo.png`, `docs/`, `.gitignore`):

```bash
cd /Users/jhordangabriel/Documents/projects
composer create-project --no-install yiisoft/yii2-app-basic citrus-tmp "~2.0.54"
rsync -a --ignore-existing citrus-tmp/ citrus/
rm -rf citrus-tmp
```

Expected: `citrus/` agora tem `composer.json`, `config/`, `controllers/`, `models/`, `views/`, `web/`, `yii`, `tests/` — sem sobrescrever os arquivos já presentes.

- [ ] **Step 2: Ajustar `composer.json`**

Garanta as dependências (acrescente `phpoffice/phpspreadsheet` às de runtime). O bloco `require` deve conter:

```json
"require": {
    "php": ">=8.2",
    "yiisoft/yii2": "~2.0.54",
    "yiisoft/yii2-bootstrap5": "~2.0.2",
    "yiisoft/yii2-symfonymailer": "~2.0.3",
    "phpoffice/phpspreadsheet": "^3.0"
}
```

- [ ] **Step 3: Criar `.docker/php/Dockerfile`**

```dockerfile
FROM php:8.2-fpm

RUN apt-get update && apt-get install -y \
    git unzip libzip-dev libpng-dev \
    && docker-php-ext-install pdo_mysql zip gd \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
```

- [ ] **Step 4: Criar `.docker/nginx/default.conf`**

```nginx
server {
    listen 80;
    server_name localhost;
    root /var/www/html/web;
    index index.php;

    resolver 127.0.0.11 valid=30s;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        set $upstream php:9000;
        fastcgi_pass $upstream;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
```

- [ ] **Step 5: Criar `docker-compose.yml`** (portas 8082 / 3308 para não colidir com london)

```yaml
services:
  nginx:
    image: nginx:1.27-alpine
    ports:
      - "8082:80"
    volumes:
      - ./:/var/www/html
      - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - php

  php:
    build: ./.docker/php
    volumes:
      - ./:/var/www/html
    env_file:
      - .env
    depends_on:
      - db

  db:
    image: mysql:8.0
    env_file:
      - .env
    ports:
      - "3308:3306"
    volumes:
      - dbdata:/var/lib/mysql

volumes:
  dbdata:
```

- [ ] **Step 6: Criar `.env.example` e `.env`**

`.env.example`:

```env
MYSQL_ROOT_PASSWORD=change_me_root
MYSQL_DATABASE=citrus
MYSQL_USER=citrus
MYSQL_PASSWORD=change_me
DB_HOST=db
DB_PORT=3306
YII_ENV=dev
YII_DEBUG=true
```

Copie para `.env`: `cp .env.example .env`

- [ ] **Step 7: Substituir `config/db.php`** (lê variáveis de ambiente)

```php
<?php

return [
    'class' => 'yii\db\Connection',
    'dsn' => 'mysql:host=' . getenv('DB_HOST') . ';port=' . getenv('DB_PORT')
        . ';dbname=' . getenv('MYSQL_DATABASE'),
    'username' => getenv('MYSQL_USER'),
    'password' => getenv('MYSQL_PASSWORD'),
    'charset' => 'utf8mb4',
];
```

- [ ] **Step 8: Definir parâmetros em `config/params.php`**

```php
<?php

return [
    'adminEmail' => 'fabricio.avelino@gmail.com',
    'senderEmail' => 'nao-responder@citrus.com.br',
    'senderName' => 'Citrus Engenharia',
    'login.maxTentativas' => 5,
    'login.bloqueioSegundos' => 900,   // 15 min
    'session.timeoutSegundos' => 3600, // 60 min
];
```

- [ ] **Step 9: Subir containers e instalar dependências**

```bash
cd /Users/jhordangabriel/Documents/projects/citrus
docker compose up -d --build
docker compose exec php composer install
```

Expected: três containers `Up`. `composer install` termina sem erro.

- [ ] **Step 10: Verificar app no ar**

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082
```

Expected: `200`.

- [ ] **Step 11: Commit**

```bash
git add citrus
git commit -m "chore(citrus): scaffold yii2 app-basic + docker (nginx/php/mysql)"
```

---

## Task 2: Localização, sessão e identidade no `config/web.php`

**Files:**
- Modify: `config/web.php`

- [ ] **Step 1: Reescrever `config/web.php`** (idioma pt-BR, timezone, identidade `Profissional`, captcha, timeout de sessão, pretty URLs)

```php
<?php

$params = require __DIR__ . '/params.php';
$db = require __DIR__ . '/db.php';

$config = [
    'id' => 'citrus',
    'name' => 'Citrus Engenharia',
    'basePath' => dirname(__DIR__),
    'bootstrap' => ['log'],
    'language' => 'pt-BR',
    'sourceLanguage' => 'en-US',
    'timeZone' => 'America/Sao_Paulo',
    'aliases' => [
        '@bower' => '@vendor/bower-asset',
        '@npm'   => '@vendor/npm-asset',
    ],
    'components' => [
        'request' => [
            // Chave estável (NÃO gerar dinamicamente — quebraria sessão/CSRF a cada request).
            'cookieValidationKey' => 'citrus-7f3a9c1e84b260d5af19c2b7e0d8164a',
        ],
        'cache' => ['class' => \yii\caching\FileCache::class],
        'session' => [
            'class' => \yii\web\Session::class,
            'timeout' => $params['session.timeoutSegundos'],
        ],
        'user' => [
            'identityClass' => \app\models\Profissional::class,
            'enableAutoLogin' => true,
            'loginUrl' => ['site/login'],
        ],
        'assetManager' => ['appendTimestamp' => true],
        'errorHandler' => ['errorAction' => 'site/error'],
        'log' => [
            'traceLevel' => YII_DEBUG ? 3 : 0,
            'targets' => [
                ['class' => \yii\log\FileTarget::class, 'levels' => ['error', 'warning']],
            ],
        ],
        'db' => $db,
        'urlManager' => [
            'enablePrettyUrl' => true,
            'showScriptName' => false,
            'rules' => [
                '' => 'site/index',
                'login' => 'site/login',
                'logout' => 'site/logout',
                '<controller:[\w-]+>/<action:[\w-]+>/<id:\d+>' => '<controller>/<action>',
                '<controller:[\w-]+>/<action:[\w-]+>' => '<controller>/<action>',
            ],
        ],
    ],
    'params' => $params,
];

if (YII_ENV_DEV) {
    $config['bootstrap'][] = 'debug';
    $config['modules']['debug'] = ['class' => \yii\debug\Module::class];
    $config['bootstrap'][] = 'gii';
    $config['modules']['gii'] = ['class' => \yii\gii\Module::class];
}

return $config;
```

> Nota: `cookieValidationKey` é uma chave fixa (obrigatório ser estável). Para produção, troque por um valor próprio e mantenha-o fora do versionamento se preferir.

- [ ] **Step 2: Verificar que o app ainda carrega**

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082
```

Expected: `200` (a home padrão ainda funciona; identidade só será exercida ao logar).

- [ ] **Step 3: Commit**

```bash
git add citrus/config/web.php
git commit -m "chore(citrus): config pt-BR, sessão, captcha e identidade Profissional"
```

---

## Task 3: Tabela e model de Auditoria

**Files:**
- Create: `migrations/m260618_000001_create_auditoria_table.php`, `models/Auditoria.php`
- Test: `tests/Unit/Models/AuditoriaTest.php`

- [ ] **Step 1: Migration da auditoria**

`migrations/m260618_000001_create_auditoria_table.php`:

```php
<?php

use yii\db\Migration;

class m260618_000001_create_auditoria_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%auditoria}}', [
            'id' => $this->primaryKey(),
            'user_id' => $this->integer()->null(),
            'acao' => $this->string(20)->notNull(),       // create|update|delete|status
            'entidade' => $this->string(100)->notNull(),
            'entidade_id' => $this->integer()->null(),
            'alteracoes' => $this->text()->null(),        // JSON
            'ip' => $this->string(45)->null(),
            'created_at' => $this->integer()->notNull(),
        ]);
        $this->createIndex('idx-auditoria-entidade', '{{%auditoria}}', ['entidade', 'entidade_id']);
        $this->createIndex('idx-auditoria-created', '{{%auditoria}}', ['created_at']);
    }

    public function safeDown()
    {
        $this->dropTable('{{%auditoria}}');
    }
}
```

- [ ] **Step 2: Model Auditoria**

`models/Auditoria.php`:

```php
<?php

namespace app\models;

use yii\db\ActiveRecord;

class Auditoria extends ActiveRecord
{
    public static function tableName()
    {
        return '{{%auditoria}}';
    }

    public function rules()
    {
        return [
            [['acao', 'entidade', 'created_at'], 'required'],
            [['user_id', 'entidade_id', 'created_at'], 'integer'],
            [['alteracoes'], 'string'],
            [['acao'], 'string', 'max' => 20],
            [['entidade'], 'string', 'max' => 100],
            [['ip'], 'string', 'max' => 45],
        ];
    }

    public function getAlteracoesArray(): array
    {
        return $this->alteracoes ? (json_decode($this->alteracoes, true) ?: []) : [];
    }
}
```

- [ ] **Step 3: Rodar a migration**

```bash
docker compose exec php php yii migrate --interactive=0
```

Expected: "Migrated up successfully" listando `create_auditoria_table`.

- [ ] **Step 4: Escrever teste do model**

`tests/Unit/Models/AuditoriaTest.php`:

```php
<?php

namespace app\tests\Unit\Models;

use app\models\Auditoria;

class AuditoriaTest extends \Codeception\Test\Unit
{
    public function testAlteracoesArrayDecodificaJson()
    {
        $a = new Auditoria(['alteracoes' => json_encode(['nome' => 'X'])]);
        $this->assertEquals(['nome' => 'X'], $a->getAlteracoesArray());
    }

    public function testAlteracoesArrayVazioQuandoNull()
    {
        $a = new Auditoria();
        $this->assertEquals([], $a->getAlteracoesArray());
    }
}
```

- [ ] **Step 5: Rodar o teste (deve passar)**

```bash
docker compose exec php vendor/bin/codecept run Unit Models/AuditoriaTest
```

Expected: PASS (2 testes).

- [ ] **Step 6: Commit**

```bash
git add citrus/migrations citrus/models/Auditoria.php citrus/tests/Unit/Models/AuditoriaTest.php
git commit -m "feat(citrus): tabela e model de auditoria"
```

---

## Task 4: Transversais — AuditBehavior, SoftDeleteQuery, BaseActiveRecord

**Files:**
- Create: `components/AuditBehavior.php`, `models/SoftDeleteQuery.php`, `models/BaseActiveRecord.php`
- Test: depende de uma entidade real (testado na Task 7 via `Funcao`); aqui só implementamos.

- [ ] **Step 1: `models/SoftDeleteQuery.php`**

```php
<?php

namespace app\models;

use yii\db\ActiveQuery;

/**
 * ActiveQuery que sempre filtra soft-deleted (deleted_at IS NULL),
 * aplicado em prepare() — resistente a ->where() do consumidor.
 */
class SoftDeleteQuery extends ActiveQuery
{
    private bool $incluiExcluidos = false;

    public function comExcluidos(): self
    {
        $this->incluiExcluidos = true;
        return $this;
    }

    public function prepare($builder)
    {
        if (!$this->incluiExcluidos) {
            $modelClass = $this->modelClass;
            $this->andWhere([$modelClass::tableName() . '.deleted_at' => null]);
        }
        return parent::prepare($builder);
    }
}
```

- [ ] **Step 2: `components/AuditBehavior.php`**

```php
<?php

namespace app\components;

use Yii;
use yii\base\Behavior;
use yii\db\ActiveRecord;
use yii\web\Request as WebRequest;
use app\models\Auditoria;

/**
 * Registra em `auditoria` criação, alteração e soft-delete do model dono.
 * NÃO anexar à própria Auditoria.
 */
class AuditBehavior extends Behavior
{
    public function events()
    {
        return [
            ActiveRecord::EVENT_AFTER_INSERT => 'afterInsert',
            ActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdate',
        ];
    }

    public function afterInsert($event)
    {
        $this->write('create', $this->owner->getAttributes());
    }

    public function afterUpdate($event)
    {
        $changed = $event->changedAttributes; // [attr => valor ANTIGO]

        if (array_key_exists('deleted_at', $changed)
            && $changed['deleted_at'] === null
            && $this->owner->deleted_at !== null) {
            $this->write('delete', ['deleted_at' => $this->owner->deleted_at]);
            return;
        }

        $diff = [];
        foreach ($changed as $attr => $old) {
            $diff[$attr] = ['de' => $old, 'para' => $this->owner->$attr];
        }
        if ($diff) {
            $this->write('update', $diff);
        }
    }

    private function write(string $acao, array $dados): void
    {
        $log = new Auditoria();
        $log->user_id = (Yii::$app->has('user', true) && !Yii::$app->user->isGuest)
            ? Yii::$app->user->id : null;
        $log->acao = $acao;
        $log->entidade = (new \ReflectionClass($this->owner))->getShortName();
        $log->entidade_id = $this->owner->getPrimaryKey();
        $log->alteracoes = json_encode($dados, JSON_UNESCAPED_UNICODE);
        $log->ip = (Yii::$app->request instanceof WebRequest)
            ? Yii::$app->request->userIP : null;
        $log->created_at = time();
        $log->save(false);
    }
}
```

- [ ] **Step 3: `models/BaseActiveRecord.php`**

```php
<?php

namespace app\models;

use yii\behaviors\TimestampBehavior;
use yii\db\ActiveRecord;
use app\components\AuditBehavior;

/**
 * Base das entidades de negócio: timestamps, auditoria e soft delete.
 * Toda subclasse DEVE ter colunas created_at, updated_at e deleted_at (inteiros).
 */
abstract class BaseActiveRecord extends ActiveRecord
{
    public function behaviors()
    {
        return [
            'timestamp' => ['class' => TimestampBehavior::class],
            'audit' => ['class' => AuditBehavior::class],
        ];
    }

    public static function find()
    {
        return new SoftDeleteQuery(static::class);
    }

    public function delete()
    {
        $this->deleted_at = time();
        return $this->save(false, ['deleted_at', 'updated_at']) ? 1 : false;
    }
}
```

- [ ] **Step 4: Checar sintaxe (lint)**

```bash
docker compose exec php php -l models/SoftDeleteQuery.php
docker compose exec php php -l components/AuditBehavior.php
docker compose exec php php -l models/BaseActiveRecord.php
```

Expected: "No syntax errors detected" para os três.

- [ ] **Step 5: Commit**

```bash
git add citrus/components/AuditBehavior.php citrus/models/SoftDeleteQuery.php citrus/models/BaseActiveRecord.php
git commit -m "feat(citrus): transversais soft-delete + auditoria (BaseActiveRecord)"
```

---

## Task 5: Migrations das entidades de domínio + seed de permissões

**Files:**
- Create: `migrations/m260618_000002_create_funcao_table.php`, `m260618_000003_create_profissional_table.php`, `m260618_000004_create_obra_table.php`, `m260618_000005_create_permissao_table.php`

- [ ] **Step 1: Migration `funcao`**

`migrations/m260618_000002_create_funcao_table.php`:

```php
<?php

use yii\db\Migration;

class m260618_000002_create_funcao_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%funcao}}', [
            'id' => $this->primaryKey(),
            'nome' => $this->string(150)->notNull(),
            'tipo_remuneracao' => "ENUM('diaria','quinzena') NOT NULL DEFAULT 'diaria'",
            'valor_base_diaria' => $this->decimal(10, 2)->null(),
            'valor_base_quinzena' => $this->decimal(10, 2)->null(),
            'status' => "ENUM('ativo','inativo') NOT NULL DEFAULT 'ativo'",
            'created_at' => $this->integer()->notNull(),
            'updated_at' => $this->integer()->notNull(),
            'deleted_at' => $this->integer()->null(),
        ]);
    }

    public function safeDown()
    {
        $this->dropTable('{{%funcao}}');
    }
}
```

- [ ] **Step 2: Migration `profissional`** (inclui colunas de identidade e bloqueio)

`migrations/m260618_000003_create_profissional_table.php`:

```php
<?php

use yii\db\Migration;

class m260618_000003_create_profissional_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%profissional}}', [
            'id' => $this->primaryKey(),
            'nome' => $this->string(180)->notNull(),
            'cpf' => $this->string(14)->notNull(),
            'cnpj' => $this->string(18)->null(),
            'razao_social' => $this->string(180)->null(),
            'data_nascimento' => $this->date()->notNull(),
            'endereco' => $this->string(255)->notNull(),
            'whatsapp' => $this->string(20)->notNull(),
            'email' => $this->string(180)->null(),
            'funcao_id' => $this->integer()->notNull(),
            'valor_diaria' => $this->decimal(10, 2)->notNull(),
            'perfis' => "SET('profissional','lider','gerente','administrativo') NOT NULL DEFAULT ''",
            'valor_bonus_mensal' => $this->decimal(10, 2)->null(),
            'login' => $this->string(60)->null(),
            'password_hash' => $this->string(255)->null(),
            'auth_key' => $this->string(32)->null(),
            'status' => "ENUM('ativo','inativo','afastado') NOT NULL DEFAULT 'ativo'",
            'failed_attempts' => $this->integer()->notNull()->defaultValue(0),
            'locked_until' => $this->integer()->null(),
            'created_at' => $this->integer()->notNull(),
            'updated_at' => $this->integer()->notNull(),
            'deleted_at' => $this->integer()->null(),
        ]);
        $this->createIndex('uq-profissional-cpf', '{{%profissional}}', 'cpf', true);
        $this->createIndex('uq-profissional-login', '{{%profissional}}', 'login', true);
        $this->addForeignKey('fk-profissional-funcao', '{{%profissional}}', 'funcao_id', '{{%funcao}}', 'id');
    }

    public function safeDown()
    {
        $this->dropTable('{{%profissional}}');
    }
}
```

- [ ] **Step 3: Migration `obra`**

`migrations/m260618_000004_create_obra_table.php`:

```php
<?php

use yii\db\Migration;

class m260618_000004_create_obra_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%obra}}', [
            'id' => $this->primaryKey(),
            'nome' => $this->string(180)->notNull(),
            'cliente' => $this->string(180)->notNull(),
            'turno' => $this->string(60)->notNull(),
            'cep' => $this->string(9)->notNull(),
            'bairro' => $this->string(120)->notNull(),
            'endereco' => $this->string(255)->notNull(),
            'numero' => $this->string(20)->notNull(),
            'uf' => $this->string(2)->notNull(),
            'cidade' => $this->string(120)->notNull(),
            'lider1_id' => $this->integer()->null(),
            'lider2_id' => $this->integer()->null(),
            'status' => "ENUM('ativo','inativo') NOT NULL DEFAULT 'ativo'",
            'created_at' => $this->integer()->notNull(),
            'updated_at' => $this->integer()->notNull(),
            'deleted_at' => $this->integer()->null(),
        ]);
        $this->addForeignKey('fk-obra-lider1', '{{%obra}}', 'lider1_id', '{{%profissional}}', 'id', 'SET NULL');
        $this->addForeignKey('fk-obra-lider2', '{{%obra}}', 'lider2_id', '{{%profissional}}', 'id', 'SET NULL');
    }

    public function safeDown()
    {
        $this->dropTable('{{%obra}}');
    }
}
```

- [ ] **Step 4: Migration `permissao` + seed do profissional admin**

`migrations/m260618_000005_create_permissao_table.php`:

```php
<?php

use yii\db\Migration;

class m260618_000005_create_permissao_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%permissao}}', [
            'id' => $this->primaryKey(),
            'perfil' => $this->string(20)->notNull(),  // lider|gerente|administrativo
            'tela' => $this->string(60)->notNull(),     // id do controller
            'acoes' => $this->string(255)->notNull(),   // csv: index,view,create,update,delete,export
        ]);
        $this->createIndex('idx-permissao-perfil', '{{%permissao}}', ['perfil', 'tela']);

        $todas = 'index,view,create,update,delete,export';
        $telasAdmin = ['funcao', 'profissional', 'obra', 'efetivo', 'card', 'relatorio'];
        foreach ($telasAdmin as $tela) {
            $this->insert('{{%permissao}}', ['perfil' => 'administrativo', 'tela' => $tela, 'acoes' => $todas]);
        }
        // Líder e Gerente acessam efetivo; gerente também vê obras (somente leitura)
        $this->insert('{{%permissao}}', ['perfil' => 'lider', 'tela' => 'efetivo', 'acoes' => 'index,view,create,update']);
        $this->insert('{{%permissao}}', ['perfil' => 'gerente', 'tela' => 'efetivo', 'acoes' => 'index,view,create,update']);
        $this->insert('{{%permissao}}', ['perfil' => 'gerente', 'tela' => 'obra', 'acoes' => 'index,view,export']);

        // Profissional administrativo inicial (funcao_id=1 será criada via seed abaixo)
        $now = time();
        $this->insert('{{%funcao}}', [
            'nome' => 'Administrativo', 'tipo_remuneracao' => 'quinzena',
            'valor_base_quinzena' => 0, 'status' => 'ativo',
            'created_at' => $now, 'updated_at' => $now,
        ]);
        $funcaoId = (int) $this->db->getLastInsertID();
        $this->insert('{{%profissional}}', [
            'nome' => 'Fabrício Avelino', 'cpf' => '000.000.000-00',
            'data_nascimento' => '1990-01-01', 'endereco' => 'Citrus Engenharia',
            'whatsapp' => '(00) 00000-0000', 'email' => 'fabricio.avelino@gmail.com',
            'funcao_id' => $funcaoId, 'valor_diaria' => 0,
            'perfis' => 'administrativo',
            'login' => 'admin',
            'password_hash' => Yii::$app->security->generatePasswordHash('citrus@2026'),
            'auth_key' => Yii::$app->security->generateRandomString(),
            'status' => 'ativo', 'failed_attempts' => 0,
            'created_at' => $now, 'updated_at' => $now,
        ]);
    }

    public function safeDown()
    {
        $this->delete('{{%profissional}}', ['login' => 'admin']);
        $this->dropTable('{{%permissao}}');
    }
}
```

- [ ] **Step 5: Rodar migrations**

```bash
docker compose exec php php yii migrate --interactive=0
```

Expected: 4 migrations aplicadas; tabelas `funcao`, `profissional`, `obra`, `permissao` criadas; admin semeado.

- [ ] **Step 6: Commit**

```bash
git add citrus/migrations
git commit -m "feat(citrus): migrations funcao/profissional/obra/permissao + seed admin"
```

---

## Task 6: Model Funcao (validação condicional por tipo de remuneração)

**Files:**
- Create: `models/Funcao.php`
- Test: `tests/Unit/Models/FuncaoTest.php`, `tests/fixtures/FuncaoFixture.php`, `tests/fixtures/data/funcao.php`

- [ ] **Step 1: Escrever o teste primeiro**

`tests/fixtures/data/funcao.php`:

```php
<?php
return [
    'pedreiro' => [
        'id' => 10, 'nome' => 'Pedreiro', 'tipo_remuneracao' => 'diaria',
        'valor_base_diaria' => 150.00, 'valor_base_quinzena' => null, 'status' => 'ativo',
        'created_at' => 1700000000, 'updated_at' => 1700000000, 'deleted_at' => null,
    ],
];
```

`tests/fixtures/FuncaoFixture.php`:

```php
<?php
namespace app\tests\fixtures;

use yii\test\ActiveFixture;

class FuncaoFixture extends ActiveFixture
{
    public $modelClass = \app\models\Funcao::class;
    public $dataFile = '@app/tests/fixtures/data/funcao.php';
}
```

`tests/Unit/Models/FuncaoTest.php`:

```php
<?php
namespace app\tests\Unit\Models;

use app\models\Funcao;

class FuncaoTest extends \Codeception\Test\Unit
{
    public function testDiariaExigeValorDiaria()
    {
        $f = new Funcao(['nome' => 'Pintor', 'tipo_remuneracao' => 'diaria', 'status' => 'ativo']);
        $this->assertFalse($f->validate());
        $this->assertArrayHasKey('valor_base_diaria', $f->errors);
    }

    public function testQuinzenaExigeValorQuinzena()
    {
        $f = new Funcao(['nome' => 'Engenheiro', 'tipo_remuneracao' => 'quinzena', 'status' => 'ativo']);
        $this->assertFalse($f->validate());
        $this->assertArrayHasKey('valor_base_quinzena', $f->errors);
    }

    public function testDiariaValidaComValor()
    {
        $f = new Funcao(['nome' => 'Pintor', 'tipo_remuneracao' => 'diaria',
            'valor_base_diaria' => 160, 'status' => 'ativo']);
        $this->assertTrue($f->validate());
    }
}
```

- [ ] **Step 2: Rodar o teste (deve falhar)**

```bash
docker compose exec php vendor/bin/codecept run Unit Models/FuncaoTest
```

Expected: FAIL ("Class app\models\Funcao not found").

- [ ] **Step 3: Implementar `models/Funcao.php`**

```php
<?php

namespace app\models;

class Funcao extends BaseActiveRecord
{
    public static function tableName()
    {
        return '{{%funcao}}';
    }

    public function rules()
    {
        return [
            [['nome', 'tipo_remuneracao', 'status'], 'required'],
            [['nome'], 'string', 'max' => 150],
            [['tipo_remuneracao'], 'in', 'range' => ['diaria', 'quinzena']],
            [['status'], 'in', 'range' => ['ativo', 'inativo']],
            [['valor_base_diaria', 'valor_base_quinzena'], 'number', 'min' => 0],
            ['valor_base_diaria', 'required', 'when' => fn($m) => $m->tipo_remuneracao === 'diaria',
                'whenClient' => "function(a){return $('#funcao-tipo_remuneracao').val()==='diaria';}",
                'message' => 'Informe o valor base da diária.'],
            ['valor_base_quinzena', 'required', 'when' => fn($m) => $m->tipo_remuneracao === 'quinzena',
                'whenClient' => "function(a){return $('#funcao-tipo_remuneracao').val()==='quinzena';}",
                'message' => 'Informe o valor base da quinzena.'],
        ];
    }

    public function attributeLabels()
    {
        return [
            'nome' => 'Nome da Função',
            'tipo_remuneracao' => 'Tipo de Remuneração',
            'valor_base_diaria' => 'Valor Base Diária',
            'valor_base_quinzena' => 'Valor Base Quinzena',
            'status' => 'Status',
        ];
    }

    public static function ativas(): array
    {
        return self::find()->where(['status' => 'ativo'])->orderBy('nome')->all();
    }
}
```

- [ ] **Step 4: Rodar o teste (deve passar)**

```bash
docker compose exec php vendor/bin/codecept run Unit Models/FuncaoTest
```

Expected: PASS (3 testes).

- [ ] **Step 5: Commit**

```bash
git add citrus/models/Funcao.php citrus/tests/Unit/Models/FuncaoTest.php citrus/tests/fixtures/FuncaoFixture.php citrus/tests/fixtures/data/funcao.php
git commit -m "feat(citrus): model Funcao com validação condicional por tipo"
```

---

## Task 7: Verificar soft-delete + auditoria via Funcao

**Files:**
- Test: `tests/Unit/SoftDeleteTest.php`, `tests/Unit/AuditBehaviorTest.php`, `tests/fixtures/AuditoriaFixture.php`, `tests/fixtures/data/auditoria.php`

- [ ] **Step 1: Fixture vazia de auditoria**

`tests/fixtures/data/auditoria.php`:

```php
<?php
return [];
```

`tests/fixtures/AuditoriaFixture.php`:

```php
<?php
namespace app\tests\fixtures;

use yii\test\ActiveFixture;

class AuditoriaFixture extends ActiveFixture
{
    public $modelClass = \app\models\Auditoria::class;
    public $dataFile = '@app/tests/fixtures/data/auditoria.php';
}
```

- [ ] **Step 2: Teste de soft-delete**

`tests/Unit/SoftDeleteTest.php`:

```php
<?php
namespace app\tests\Unit;

use app\models\Funcao;

class SoftDeleteTest extends \Codeception\Test\Unit
{
    private function nova(): Funcao
    {
        $f = new Funcao(['nome' => 'Temp', 'tipo_remuneracao' => 'diaria',
            'valor_base_diaria' => 100, 'status' => 'ativo']);
        $this->assertTrue($f->save());
        return $f;
    }

    public function testDeleteMarcaDeletedAt()
    {
        $f = $this->nova();
        $id = $f->id;
        $f->delete();
        $this->assertNull(Funcao::findOne($id));                       // some das queries padrão
        $this->assertNotNull(Funcao::find()->comExcluidos()->where(['id' => $id])->one());
    }
}
```

- [ ] **Step 3: Teste de auditoria**

`tests/Unit/AuditBehaviorTest.php`:

```php
<?php
namespace app\tests\Unit;

use app\models\Funcao;
use app\models\Auditoria;

class AuditBehaviorTest extends \Codeception\Test\Unit
{
    public function _fixtures(): array
    {
        return ['auditoria' => \app\tests\fixtures\AuditoriaFixture::class];
    }

    private function nova(string $nome): Funcao
    {
        $f = new Funcao(['nome' => $nome, 'tipo_remuneracao' => 'diaria',
            'valor_base_diaria' => 100, 'status' => 'ativo']);
        $this->assertTrue($f->save());
        return $f;
    }

    public function testRegistraCreate()
    {
        $f = $this->nova('Audit Create');
        $log = Auditoria::find()->where(['entidade' => 'Funcao', 'entidade_id' => $f->id, 'acao' => 'create'])->one();
        $this->assertNotNull($log);
    }

    public function testRegistraUpdateComDiff()
    {
        $f = $this->nova('Audit Update');
        $f->nome = 'Audit Update 2';
        $this->assertTrue($f->save());
        $log = Auditoria::find()->where(['entidade' => 'Funcao', 'entidade_id' => $f->id, 'acao' => 'update'])
            ->orderBy(['id' => SORT_DESC])->one();
        $this->assertEquals('Audit Update', $log->getAlteracoesArray()['nome']['de']);
        $this->assertEquals('Audit Update 2', $log->getAlteracoesArray()['nome']['para']);
    }

    public function testRegistraDelete()
    {
        $f = $this->nova('Audit Delete');
        $id = $f->id;
        $f->delete();
        $log = Auditoria::find()->where(['entidade' => 'Funcao', 'entidade_id' => $id, 'acao' => 'delete'])->one();
        $this->assertNotNull($log);
    }
}
```

- [ ] **Step 4: Garantir o banco de teste migrado e rodar**

```bash
docker compose exec php php yii_test migrate --interactive=0 2>/dev/null || docker compose exec php php yii migrate --interactive=0
docker compose exec php vendor/bin/codecept run Unit SoftDeleteTest
docker compose exec php vendor/bin/codecept run Unit AuditBehaviorTest
```

Expected: ambos PASS. (Se `config/test_db.php` apontar para outro schema, ajuste `.env` de teste para o mesmo `citrus`; o template usa o mesmo db com prefixo de transação por teste.)

- [ ] **Step 5: Commit**

```bash
git add citrus/tests
git commit -m "test(citrus): soft-delete e auditoria validados via Funcao"
```

---

## Task 8: Model Profissional como identidade (perfis, senha, validação condicional)

**Files:**
- Create: `models/Profissional.php`
- Test: `tests/Unit/Models/ProfissionalTest.php`, `tests/fixtures/ProfissionalFixture.php`, `tests/fixtures/data/profissional.php`

- [ ] **Step 1: Fixtures**

`tests/fixtures/data/profissional.php`:

```php
<?php
return [
    'admin' => [
        'id' => 1, 'nome' => 'Admin Teste', 'cpf' => '111.111.111-11',
        'data_nascimento' => '1990-01-01', 'endereco' => 'Rua A', 'whatsapp' => '(11) 90000-0000',
        'email' => 'admin@teste.com', 'funcao_id' => 10, 'valor_diaria' => 0,
        'perfis' => 'administrativo', 'login' => 'admin',
        'password_hash' => '$2y$13$0123456789012345678901', 'auth_key' => 'k1',
        'status' => 'ativo', 'failed_attempts' => 0, 'locked_until' => null,
        'created_at' => 1700000000, 'updated_at' => 1700000000, 'deleted_at' => null,
    ],
];
```

`tests/fixtures/ProfissionalFixture.php`:

```php
<?php
namespace app\tests\fixtures;

use yii\test\ActiveFixture;

class ProfissionalFixture extends ActiveFixture
{
    public $modelClass = \app\models\Profissional::class;
    public $dataFile = '@app/tests/fixtures/data/profissional.php';
    public $depends = [\app\tests\fixtures\FuncaoFixture::class];
}
```

- [ ] **Step 2: Teste primeiro**

`tests/Unit/Models/ProfissionalTest.php`:

```php
<?php
namespace app\tests\Unit\Models;

use app\models\Profissional;

class ProfissionalTest extends \Codeception\Test\Unit
{
    public function _fixtures(): array
    {
        return [
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
        ];
    }

    private function base(array $over = []): Profissional
    {
        return new Profissional(array_merge([
            'nome' => 'João', 'cpf' => '222.222.222-22', 'data_nascimento' => '1995-05-05',
            'endereco' => 'Rua B', 'whatsapp' => '(11) 91111-1111', 'funcao_id' => 10,
            'valor_diaria' => 150, 'perfis' => 'profissional', 'status' => 'ativo',
        ], $over));
    }

    public function testProfissionalPuroNaoExigeLogin()
    {
        $this->assertTrue($this->base()->validate(), implode(',', array_keys($this->base()->errors)));
    }

    public function testPerfilAdminExigeLoginESenha()
    {
        $p = $this->base(['perfis' => 'administrativo']);
        $this->assertFalse($p->validate());
        $this->assertArrayHasKey('login', $p->errors);
        $this->assertArrayHasKey('senha', $p->errors);
    }

    public function testCpfDuplicadoInvalido()
    {
        $p = $this->base(['cpf' => '111.111.111-11']); // já existe na fixture
        $this->assertFalse($p->validate());
        $this->assertArrayHasKey('cpf', $p->errors);
    }

    public function testTemPerfil()
    {
        $p = $this->base(['perfis' => 'lider,gerente']);
        $this->assertTrue($p->temPerfil('lider'));
        $this->assertFalse($p->temPerfil('administrativo'));
    }

    public function testSetPasswordGeraHash()
    {
        $p = $this->base();
        $p->setPassword('segredo123');
        $this->assertTrue($p->validatePassword('segredo123'));
        $this->assertFalse($p->validatePassword('errado'));
    }
}
```

- [ ] **Step 3: Rodar (deve falhar)**

```bash
docker compose exec php vendor/bin/codecept run Unit Models/ProfissionalTest
```

Expected: FAIL ("Class app\models\Profissional not found").

- [ ] **Step 4: Implementar `models/Profissional.php`**

```php
<?php

namespace app\models;

use Yii;
use yii\base\NotSupportedException;
use yii\web\IdentityInterface;

/**
 * @property string $perfis  CSV de perfis (coluna SET)
 */
class Profissional extends BaseActiveRecord implements IdentityInterface
{
    public const PERFIS = ['profissional', 'lider', 'gerente', 'administrativo'];
    public const PERFIS_COM_LOGIN = ['lider', 'gerente', 'administrativo'];

    /** Campos virtuais do formulário (não persistidos diretamente). */
    public ?string $senha = null;
    public array $perfisArray = [];

    public static function tableName()
    {
        return '{{%profissional}}';
    }

    public function rules()
    {
        return [
            [['nome', 'cpf', 'data_nascimento', 'endereco', 'whatsapp', 'funcao_id', 'valor_diaria', 'status'], 'required'],
            [['perfis'], 'required', 'message' => 'Selecione ao menos um perfil.'],
            [['nome', 'razao_social'], 'string', 'max' => 180],
            [['cpf'], 'string', 'max' => 14],
            [['cpf'], 'unique', 'message' => 'CPF já cadastrado.'],
            [['cnpj'], 'string', 'max' => 18],
            [['whatsapp'], 'string', 'max' => 20],
            [['email'], 'email'],
            [['login'], 'string', 'max' => 60],
            [['login'], 'unique'],
            [['data_nascimento'], 'date', 'format' => 'php:Y-m-d'],
            [['funcao_id'], 'exist', 'targetClass' => Funcao::class, 'targetAttribute' => 'id'],
            [['valor_diaria', 'valor_bonus_mensal'], 'number', 'min' => 0],
            [['status'], 'in', 'range' => ['ativo', 'inativo', 'afastado']],
            // login/senha obrigatórios se tiver perfil com acesso ao sistema
            ['login', 'required', 'when' => fn($m) => $m->precisaLogin(),
                'message' => 'Login é obrigatório para perfis com acesso.'],
            ['senha', 'required', 'when' => fn($m) => $m->precisaLogin() && $m->isNewRecord,
                'message' => 'Senha é obrigatória para perfis com acesso.'],
            ['senha', 'string', 'min' => 6],
            // bônus só faz sentido para líder
            ['valor_bonus_mensal', 'required', 'when' => fn($m) => $m->temPerfil('lider'),
                'message' => 'Informe o bônus mensal do líder.'],
        ];
    }

    public function attributeLabels()
    {
        return [
            'nome' => 'Nome Completo', 'cpf' => 'CPF', 'cnpj' => 'CNPJ',
            'razao_social' => 'Razão Social', 'data_nascimento' => 'Data de Nascimento',
            'endereco' => 'Endereço', 'whatsapp' => 'WhatsApp', 'email' => 'E-mail',
            'funcao_id' => 'Função', 'valor_diaria' => 'Valor Diária',
            'perfis' => 'Perfis', 'valor_bonus_mensal' => 'Valor Bônus Mensal',
            'login' => 'Login', 'senha' => 'Senha', 'status' => 'Status',
        ];
    }

    public function getPerfisLista(): array
    {
        return $this->perfis ? explode(',', $this->perfis) : [];
    }

    public function temPerfil(string $perfil): bool
    {
        return in_array($perfil, $this->getPerfisLista(), true);
    }

    public function precisaLogin(): bool
    {
        return (bool) array_intersect($this->getPerfisLista(), self::PERFIS_COM_LOGIN);
    }

    public function beforeSave($insert)
    {
        if (!parent::beforeSave($insert)) {
            return false;
        }
        if ($this->senha) {
            $this->setPassword($this->senha);
        }
        if ($insert && !$this->auth_key) {
            $this->auth_key = Yii::$app->security->generateRandomString();
        }
        return true;
    }

    public function getFuncao()
    {
        return $this->hasOne(Funcao::class, ['id' => 'funcao_id']);
    }

    // ---- Senha ----
    public function setPassword(string $password): void
    {
        $this->password_hash = Yii::$app->security->generatePasswordHash($password);
    }

    public function validatePassword(string $password): bool
    {
        return $this->password_hash && Yii::$app->security->validatePassword($password, $this->password_hash);
    }

    // ---- IdentityInterface ----
    public static function findIdentity($id)
    {
        return static::findOne(['id' => $id, 'status' => 'ativo']);
    }

    public static function findIdentityByAccessToken($token, $type = null)
    {
        throw new NotSupportedException('Token auth não suportado.');
    }

    public static function findByLogin(string $login): ?self
    {
        return static::findOne(['login' => $login, 'status' => 'ativo']);
    }

    public function getId()
    {
        return $this->getPrimaryKey();
    }

    public function getAuthKey()
    {
        return $this->auth_key;
    }

    public function validateAuthKey($authKey)
    {
        return $this->auth_key === $authKey;
    }

    /** Lista profissionais com determinado perfil (para selects de líder). */
    public static function comPerfil(string $perfil): array
    {
        return self::find()
            ->where(new \yii\db\Expression('FIND_IN_SET(:p, perfis)', [':p' => $perfil]))
            ->andWhere(['status' => 'ativo'])
            ->orderBy('nome')->all();
    }
}
```

- [ ] **Step 5: Rodar (deve passar)**

```bash
docker compose exec php vendor/bin/codecept run Unit Models/ProfissionalTest
```

Expected: PASS (5 testes).

- [ ] **Step 6: Commit**

```bash
git add citrus/models/Profissional.php citrus/tests/Unit/Models/ProfissionalTest.php citrus/tests/fixtures/ProfissionalFixture.php citrus/tests/fixtures/data/profissional.php
git commit -m "feat(citrus): model Profissional (identidade, perfis, validação condicional)"
```

---

## Task 9: LoginForm com captcha e bloqueio por tentativas + SiteController

**Files:**
- Create: `models/LoginForm.php`
- Modify: `controllers/SiteController.php`, `views/site/login.php`
- Test: `tests/functional/LoginCest.php`

- [ ] **Step 1: `models/LoginForm.php`** (captcha + bloqueio)

```php
<?php

namespace app\models;

use Yii;
use yii\base\Model;

class LoginForm extends Model
{
    public string $login = '';
    public string $password = '';
    public string $verifyCode = '';
    public bool $rememberMe = true;

    private ?Profissional $_user = null;
    private bool $_loaded = false;

    public function rules()
    {
        return [
            [['login', 'password'], 'required'],
            ['rememberMe', 'boolean'],
            ['verifyCode', 'captcha'],
            ['password', 'validatePassword'],
        ];
    }

    public function attributeLabels()
    {
        return ['login' => 'Login', 'password' => 'Senha', 'verifyCode' => 'Código de verificação'];
    }

    public function validatePassword($attribute, $params): void
    {
        if ($this->hasErrors()) {
            return;
        }
        $user = $this->getUser();
        if ($user && $user->locked_until && $user->locked_until > time()) {
            $faltam = (int) ceil(($user->locked_until - time()) / 60);
            $this->addError($attribute, "Conta bloqueada. Tente novamente em {$faltam} min.");
            return;
        }
        if (!$user || !$user->validatePassword($this->password)) {
            $this->registrarFalha($user);
            $this->addError($attribute, 'Login ou senha incorretos.');
        }
    }

    private function registrarFalha(?Profissional $user): void
    {
        if (!$user) {
            return;
        }
        $max = (int) Yii::$app->params['login.maxTentativas'];
        $user->failed_attempts++;
        if ($user->failed_attempts >= $max) {
            $user->locked_until = time() + (int) Yii::$app->params['login.bloqueioSegundos'];
            $user->failed_attempts = 0;
        }
        $user->updateAttributes(['failed_attempts', 'locked_until']);
    }

    public function login(): bool
    {
        if (!$this->validate()) {
            return false;
        }
        $user = $this->getUser();
        $user->updateAttributes(['failed_attempts' => 0, 'locked_until' => null]);
        return Yii::$app->user->login($user, $this->rememberMe ? 3600 * 24 * 30 : 0);
    }

    public function getUser(): ?Profissional
    {
        if (!$this->_loaded) {
            $this->_user = Profissional::findByLogin($this->login);
            $this->_loaded = true;
        }
        return $this->_user;
    }
}
```

- [ ] **Step 2: Reescrever `controllers/SiteController.php`** (login/logout, captcha, dashboard stub, error)

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use Yii;
use app\models\LoginForm;
use app\models\Obra;
use app\models\Profissional;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\ErrorAction;
use yii\captcha\CaptchaAction;
use yii\web\Response;

class SiteController extends Controller
{
    public function behaviors(): array
    {
        return [
            'access' => [
                'class' => AccessControl::class,
                'rules' => [
                    ['actions' => ['login', 'error', 'captcha'], 'allow' => true],
                    ['allow' => true, 'roles' => ['@']],
                ],
            ],
            'verbs' => [
                'class' => VerbFilter::class,
                'actions' => ['logout' => ['post']],
            ],
        ];
    }

    public function actions(): array
    {
        return [
            'error' => ['class' => ErrorAction::class],
            'captcha' => ['class' => CaptchaAction::class, 'maxLength' => 5],
        ];
    }

    public function actionIndex(): string
    {
        return $this->render('index', [
            'obrasAtivas' => Obra::find()->where(['status' => 'ativo'])->count(),
            'profissionaisAtivos' => Profissional::find()->where(['status' => 'ativo'])->count(),
        ]);
    }

    public function actionLogin()
    {
        $this->layout = 'main';
        if (!Yii::$app->user->isGuest) {
            return $this->goHome();
        }
        $model = new LoginForm();
        if ($model->load(Yii::$app->request->post()) && $model->login()) {
            return $this->goBack();
        }
        $model->password = '';
        return $this->render('login', ['model' => $model]);
    }

    public function actionLogout(): Response
    {
        Yii::$app->user->logout();
        return $this->goHome();
    }
}
```

- [ ] **Step 3: View `views/site/login.php`** (tema claro, lime)

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\LoginForm $model */

use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;
use yii\captcha\Captcha;

$this->title = 'Entrar';
?>
<div class="login-wrap">
    <div class="login-card">
        <?= Html::img('@web/img/logo.png', ['alt' => 'Citrus Engenharia', 'class' => 'login-logo']) ?>
        <h1 class="login-title">Acessar o sistema</h1>

        <?php $form = ActiveForm::begin(['id' => 'login-form']); ?>
            <?= $form->field($model, 'login')->textInput(['autofocus' => true, 'autocomplete' => 'username']) ?>
            <?= $form->field($model, 'password')->passwordInput(['autocomplete' => 'current-password']) ?>
            <?= $form->field($model, 'verifyCode')->widget(Captcha::class, [
                'captchaAction' => 'site/captcha',
                'template' => '<div class="captcha-row">{image}{input}</div>',
            ]) ?>
            <?= $form->field($model, 'rememberMe')->checkbox() ?>
            <?= Html::submitButton('Entrar', ['class' => 'btn btn-lime w-100', 'name' => 'login-button']) ?>
        <?php ActiveForm::end(); ?>
    </div>
</div>
```

- [ ] **Step 4: Teste funcional `tests/functional/LoginCest.php`**

```php
<?php

class LoginCest
{
    public function _before(\FunctionalTester $I)
    {
        // seed admin via fixture
        $I->haveFixtures([
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
        ]);
    }

    public function abreFormDeLogin(\FunctionalTester $I)
    {
        $I->amOnRoute('site/login');
        $I->see('Acessar o sistema');
        $I->seeElement('input[name="LoginForm[login]"]');
    }

    public function loginInvalidoMostraErro(\FunctionalTester $I)
    {
        $I->amOnRoute('site/login');
        $I->submitForm('#login-form', [
            'LoginForm[login]' => 'admin',
            'LoginForm[password]' => 'errado',
            'LoginForm[verifyCode]' => 'testme', // captcha fixo em ambiente de teste
        ]);
        $I->see('Login ou senha incorretos');
    }
}
```

> O captcha em ambiente de teste aceita `testme` (config padrão do `CaptchaAction` em testes do template Yii2). Se necessário, ajuste `fixVerifyCode` em `config/test.php`.

- [ ] **Step 5: Rodar testes funcionais**

```bash
docker compose exec php vendor/bin/codecept run functional LoginCest
```

Expected: PASS (2 cenários).

- [ ] **Step 6: Commit**

```bash
git add citrus/models/LoginForm.php citrus/controllers/SiteController.php citrus/views/site/login.php citrus/tests/functional/LoginCest.php
git commit -m "feat(citrus): login com captcha e bloqueio por tentativas"
```

---

## Task 10: Controle de acesso por permissões + filtro reutilizável

**Files:**
- Create: `models/Permissao.php`, `components/PermissaoBehavior.php`

- [ ] **Step 1: Model `models/Permissao.php`**

```php
<?php

namespace app\models;

use yii\db\ActiveRecord;

class Permissao extends ActiveRecord
{
    public static function tableName()
    {
        return '{{%permissao}}';
    }

    public function rules()
    {
        return [
            [['perfil', 'tela', 'acoes'], 'required'],
            [['perfil'], 'string', 'max' => 20],
            [['tela'], 'string', 'max' => 60],
            [['acoes'], 'string', 'max' => 255],
        ];
    }

    /** O profissional pode executar $acao na $tela? */
    public static function permite(Profissional $user, string $tela, string $acao): bool
    {
        $perfis = $user->getPerfisLista();
        if (!$perfis) {
            return false;
        }
        $rows = self::find()->where(['perfil' => $perfis, 'tela' => $tela])->all();
        foreach ($rows as $row) {
            if (in_array($acao, explode(',', $row->acoes), true)) {
                return true;
            }
        }
        return false;
    }
}
```

- [ ] **Step 2: `components/PermissaoBehavior.php`** (anexável como `access` nos controllers de cadastro)

```php
<?php

namespace app\components;

use Yii;
use yii\base\ActionFilter;
use yii\web\ForbiddenHttpException;
use app\models\Permissao;

/**
 * Filtro de acesso baseado na tabela `permissao`. A `tela` é o id do controller;
 * a `acao` é o id da action. Exige usuário logado.
 */
class PermissaoBehavior extends ActionFilter
{
    public function beforeAction($action)
    {
        $user = Yii::$app->user;
        if ($user->isGuest) {
            $user->loginRequired();
            return false;
        }
        $tela = $action->controller->id;
        $acao = $action->id;
        if (!Permissao::permite($user->identity, $tela, $acao)) {
            throw new ForbiddenHttpException('Você não tem permissão para acessar esta tela.');
        }
        return true;
    }
}
```

- [ ] **Step 3: Lint**

```bash
docker compose exec php php -l models/Permissao.php
docker compose exec php php -l components/PermissaoBehavior.php
```

Expected: "No syntax errors detected".

- [ ] **Step 4: Commit**

```bash
git add citrus/models/Permissao.php citrus/components/PermissaoBehavior.php
git commit -m "feat(citrus): controle de acesso por tabela de permissões"
```

---

## Task 11: ExportHelper (Excel)

**Files:**
- Create: `components/ExportHelper.php`
- Test: `tests/Unit/ExportHelperTest.php`

- [ ] **Step 1: Teste primeiro**

`tests/Unit/ExportHelperTest.php`:

```php
<?php
namespace app\tests\Unit;

use app\components\ExportHelper;
use PhpOffice\PhpSpreadsheet\IOFactory;

class ExportHelperTest extends \Codeception\Test\Unit
{
    public function testGeraArquivoComCabecalhoELinhas()
    {
        $tmp = tempnam(sys_get_temp_dir(), 'xlsx') . '.xlsx';
        ExportHelper::salvar($tmp, ['Nome', 'Status'], [['Pedreiro', 'ativo'], ['Pintor', 'inativo']]);

        $sheet = IOFactory::load($tmp)->getActiveSheet();
        $this->assertEquals('Nome', $sheet->getCell('A1')->getValue());
        $this->assertEquals('Pedreiro', $sheet->getCell('A2')->getValue());
        $this->assertEquals('inativo', $sheet->getCell('B3')->getValue());
        @unlink($tmp);
    }
}
```

- [ ] **Step 2: Rodar (deve falhar)**

```bash
docker compose exec php vendor/bin/codecept run Unit ExportHelperTest
```

Expected: FAIL ("Class app\components\ExportHelper not found").

- [ ] **Step 3: Implementar `components/ExportHelper.php`**

```php
<?php

namespace app\components;

use Yii;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use yii\web\Response;

class ExportHelper
{
    /** Escreve cabeçalho + linhas numa planilha e salva no caminho dado. */
    public static function salvar(string $path, array $headers, array $rows): void
    {
        $book = new Spreadsheet();
        $sheet = $book->getActiveSheet();
        $sheet->fromArray($headers, null, 'A1');
        if ($rows) {
            $sheet->fromArray($rows, null, 'A2');
        }
        (new Xlsx($book))->save($path);
        $book->disconnectWorksheets();
    }

    /** Monta uma Response de download .xlsx a partir de cabeçalho + linhas. */
    public static function download(string $filename, array $headers, array $rows): Response
    {
        $tmp = tempnam(sys_get_temp_dir(), 'xlsx');
        self::salvar($tmp, $headers, $rows);
        $response = Yii::$app->response;
        $response->format = Response::FORMAT_RAW;
        $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
        $response->headers->set('Content-Disposition', "attachment; filename=\"{$filename}.xlsx\"");
        $response->content = file_get_contents($tmp);
        @unlink($tmp);
        return $response;
    }
}
```

- [ ] **Step 4: Rodar (deve passar)**

```bash
docker compose exec php vendor/bin/codecept run Unit ExportHelperTest
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add citrus/components/ExportHelper.php citrus/tests/Unit/ExportHelperTest.php
git commit -m "feat(citrus): helper de exportação para Excel"
```

---

## Task 12: Tema, AppAsset e favicon (isologo)

**Files:**
- Create: `web/css/app.css`, `assets/AppAsset.php`, `web/js/mask.js`, `web/site.webmanifest`
- Create (binário): `web/img/logo.png` (cópia da `logo.png`), favicons em `web/`

- [ ] **Step 1: Copiar a logo para assets e gerar favicons a partir da isologo**

Roda no host (macOS tem `sips`):

```bash
cd /Users/jhordangabriel/Documents/projects/citrus
mkdir -p web/img
cp logo.png web/img/logo.png
sips -z 16 16   isologo.png --out web/favicon-16x16.png
sips -z 32 32   isologo.png --out web/favicon-32x32.png
sips -z 180 180 isologo.png --out web/apple-touch-icon.png
sips -z 192 192 isologo.png --out web/icon-192.png
sips -z 512 512 isologo.png --out web/icon-512.png
```

Expected: cinco PNGs criados em `web/` e a logo em `web/img/`.

- [ ] **Step 2: `web/site.webmanifest`**

```json
{
  "name": "Citrus Engenharia",
  "short_name": "Citrus",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ],
  "theme_color": "#0C0C0C",
  "background_color": "#F6F7F8",
  "display": "standalone",
  "start_url": "/"
}
```

- [ ] **Step 3: `web/js/mask.js`** (máscaras simples de CPF/CNPJ/CEP/WhatsApp)

```javascript
(function () {
  function mask(el, fmt) {
    el.addEventListener('input', function () {
      let v = el.value.replace(/\D/g, '');
      let out = '', i = 0;
      for (const c of fmt) {
        if (i >= v.length) break;
        if (c === '#') { out += v[i++]; } else { out += c; }
      }
      el.value = out;
    });
  }
  document.querySelectorAll('[data-mask]').forEach(function (el) {
    mask(el, el.getAttribute('data-mask'));
  });
})();
```

- [ ] **Step 4: `assets/AppAsset.php`**

```php
<?php

namespace app\assets;

use yii\web\AssetBundle;

class AppAsset extends AssetBundle
{
    public $basePath = '@webroot';
    public $baseUrl = '@web';
    public $css = [
        'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
        'css/app.css',
    ];
    public $js = [
        'js/mask.js',
    ];
    public $depends = [
        'yii\web\YiiAsset',
        'yii\bootstrap5\BootstrapAsset',
        'yii\bootstrap5\BootstrapPluginAsset',
    ];
}
```

- [ ] **Step 5: `web/css/app.css`** (tokens da marca + shell + login)

```css
:root{
  --lime:#C0F024; --lime-700:#a8d40f; --green:#9CCC6C;
  --ink:#0C0C0C; --gray-700:#303030; --gray-500:#787878; --gray-300:#cfd2d6;
  --bg:#F6F7F8; --surface:#ffffff; --border:#ececec; --radius:14px;
  --safe-b:env(safe-area-inset-bottom,0px);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit}

/* Botão lime */
.btn-lime{background:var(--lime);border:none;color:var(--ink);font-weight:700;border-radius:12px;padding:12px 18px}
.btn-lime:hover{background:var(--lime-700)}

/* Login */
.login-wrap{min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:24px}
.login-card{width:100%;max-width:360px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:28px}
.login-logo{height:42px;display:block;margin:0 auto 18px}
.login-title{font-size:18px;font-weight:700;text-align:center;margin:0 0 18px}
.captcha-row{display:flex;gap:10px;align-items:center}

/* Shell */
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:10px;background:var(--surface);border-bottom:1px solid var(--border);padding:10px 16px}
.topbar .brand img{height:26px}
.topbar-title{font-weight:600;color:var(--gray-700)}
.app-shell{max-width:480px;margin:0 auto}
.content{padding:16px 16px 96px}

/* Cards de listagem */
.card-list{display:flex;flex-direction:column;gap:10px}
.entity-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px;display:flex;justify-content:space-between;align-items:center}
.entity-card h3{margin:0;font-size:15px}
.entity-card .meta{color:var(--gray-500);font-size:12px;margin-top:2px}
.badge-status{font-size:11px;padding:3px 8px;border-radius:20px;background:#eef7d8;color:#5a7d00}
.badge-status.off{background:#f1f1f1;color:#999}

/* Bottom nav + FAB */
.bottom-nav{position:fixed;left:0;right:0;bottom:0;z-index:30;display:flex;justify-content:space-around;align-items:flex-end;
  background:var(--surface);border-top:1px solid var(--border);padding:8px 6px calc(8px + var(--safe-b));max-width:480px;margin:0 auto}
.bottom-nav__item{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;
  color:var(--gray-500);font-size:10px;text-decoration:none;cursor:pointer}
.bottom-nav__item svg{width:22px;height:22px}
.bottom-nav__item.active{color:var(--ink)}
.bottom-nav__fab{margin-top:-26px}
.bottom-nav__fab .fab{width:50px;height:50px;border-radius:50%;background:var(--lime);color:var(--ink);
  display:flex;align-items:center;justify-content:center;box-shadow:0 6px 16px rgba(0,0,0,.18)}
.bottom-nav__fab .fab svg{width:26px;height:26px}

/* Sheet "Mais" */
.sheet{position:fixed;inset:0;z-index:40;background:rgba(0,0,0,.35);display:none}
.sheet.open{display:block}
.sheet__panel{position:absolute;left:0;right:0;bottom:0;background:var(--surface);border-radius:18px 18px 0 0;
  padding:14px 14px calc(14px + var(--safe-b));max-width:480px;margin:0 auto;display:flex;flex-direction:column;gap:4px}
.sheet__item{display:flex;align-items:center;gap:12px;padding:14px;border:0;background:none;border-radius:12px;
  text-decoration:none;color:var(--ink);font-size:15px;cursor:pointer}
.sheet__item:hover{background:var(--bg)}
.sheet__item svg{width:22px;height:22px;color:var(--gray-500)}

/* KPIs */
.kpi-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px}
.kpi .v{font-size:24px;font-weight:800}
.kpi .l{font-size:11px;color:var(--gray-500)}
.em-breve{text-align:center;color:var(--gray-500);padding:60px 20px}
```

- [ ] **Step 6: Verificar o CSS servido**

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8082/css/app.css
```

Expected: `200`.

- [ ] **Step 7: Commit**

```bash
git add citrus/web citrus/assets/AppAsset.php
git commit -m "feat(citrus): tema claro/lime, AppAsset, favicon a partir da isologo"
```

---

## Task 13: App-shell (layout principal com bottom-nav, FAB e sheet por perfil)

**Files:**
- Modify: `views/layouts/main.php`
- Create: `views/site/index.php`, `views/site/em-breve.php`
- Create: `controllers/EfetivoController.php`, `controllers/CardController.php`, `controllers/RelatorioController.php` (stubs)

- [ ] **Step 1: Reescrever `views/layouts/main.php`**

```php
<?php
/** @var yii\web\View $this */
/** @var string $content */

use app\assets\AppAsset;
use app\models\Permissao;
use yii\helpers\Html;
use yii\helpers\Url;

AppAsset::register($this);

$icons = [
    'inicio'   => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10"/></svg>',
    'obra'     => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-3"/></svg>',
    'efetivo'  => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3 8-8"/><path d="M20 12v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9"/></svg>',
    'mais'     => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
    'plus'     => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
    'cadastro' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 9h8M8 13h5"/></svg>',
    'card'     => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18"/></svg>',
    'relatorio'=> '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5a2 2 0 0 1 2-2h9l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/><path d="M8 13h8M8 16h5"/></svg>',
    'logout'   => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M14 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-2"/><path d="M18 15l3-3-3-3M21 12H9"/></svg>',
];

$user = Yii::$app->user->isGuest ? null : Yii::$app->user->identity;
$current = Yii::$app->controller->id;

// Permissão de tela conforme perfis
$pode = fn(string $tela) => $user && Permissao::permite($user, $tela, 'index');

// Abas primárias (filtradas por permissão)
$tabs = [];
$tabs[] = ['label' => 'Início', 'ctrl' => 'site', 'url' => ['site/index'], 'icon' => 'inicio'];
if ($pode('obra'))    { $tabs[] = ['label' => 'Obras', 'ctrl' => 'obra', 'url' => ['obra/index'], 'icon' => 'obra']; }
if ($pode('efetivo')) { $tabs[] = ['label' => 'Efetivo', 'ctrl' => 'efetivo', 'url' => ['efetivo/index'], 'icon' => 'efetivo']; }

// Itens do "Mais"
$mais = [];
if ($pode('card'))         { $mais[] = ['label' => 'Cards', 'ctrl' => 'card', 'url' => ['card/index'], 'icon' => 'card']; }
if ($pode('relatorio'))    { $mais[] = ['label' => 'Relatórios', 'ctrl' => 'relatorio', 'url' => ['relatorio/index'], 'icon' => 'relatorio']; }
if ($pode('funcao'))       { $mais[] = ['label' => 'Funções', 'ctrl' => 'funcao', 'url' => ['funcao/index'], 'icon' => 'cadastro']; }
if ($pode('profissional')) { $mais[] = ['label' => 'Profissionais', 'ctrl' => 'profissional', 'url' => ['profissional/index'], 'icon' => 'cadastro']; }
if ($pode('obra'))         { $mais[] = ['label' => 'Obras (cadastro)', 'ctrl' => 'obra', 'url' => ['obra/index'], 'icon' => 'cadastro']; }
?>
<?php $this->beginPage() ?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="<?= Yii::$app->charset ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
    <?php $this->registerCsrfMetaTags() ?>
    <title><?= Html::encode($this->title) ?> · Citrus Engenharia</title>
    <link rel="icon" type="image/png" sizes="32x32" href="<?= Yii::getAlias('@web/favicon-32x32.png') ?>">
    <link rel="icon" type="image/png" sizes="16x16" href="<?= Yii::getAlias('@web/favicon-16x16.png') ?>">
    <link rel="apple-touch-icon" href="<?= Yii::getAlias('@web/apple-touch-icon.png') ?>">
    <link rel="manifest" href="<?= Yii::getAlias('@web/site.webmanifest') ?>">
    <meta name="theme-color" content="#0C0C0C">
    <?php $this->head() ?>
</head>
<body>
<?php $this->beginBody() ?>

<?php if (Yii::$app->user->isGuest): ?>
    <?= $content ?>
<?php else: ?>
    <header class="topbar">
        <span class="brand"><?= Html::img('@web/img/logo.png', ['alt' => 'Citrus Engenharia']) ?></span>
        <span class="topbar-title"><?= Html::encode($this->title) ?></span>
    </header>

    <div class="app-shell">
        <main class="content"><?= $content ?></main>
    </div>

    <nav class="bottom-nav" aria-label="Navegação">
        <?php foreach ($tabs as $i => $item): ?>
            <a class="bottom-nav__item<?= $current === $item['ctrl'] ? ' active' : '' ?>" href="<?= Url::to($item['url']) ?>">
                <?= $icons[$item['icon']] ?><span><?= Html::encode($item['label']) ?></span>
            </a>
            <?php if ($i === (int) floor((count($tabs) - 1) / 2)): ?>
                <a class="bottom-nav__item bottom-nav__fab" href="<?= Url::to(['efetivo/create']) ?>" aria-label="Registrar efetivo">
                    <span class="fab"><?= $icons['plus'] ?></span>
                </a>
            <?php endif; ?>
        <?php endforeach; ?>
        <button type="button" class="bottom-nav__item" aria-haspopup="true" aria-controls="mais-sheet"
                onclick="document.getElementById('mais-sheet').classList.toggle('open')">
            <?= $icons['mais'] ?><span>Mais</span>
        </button>
    </nav>

    <div class="sheet" id="mais-sheet" role="menu"
         onclick="if(event.target===this)this.classList.remove('open')">
        <div class="sheet__panel">
            <?php foreach ($mais as $item): ?>
                <a class="sheet__item" role="menuitem" href="<?= Url::to($item['url']) ?>">
                    <?= $icons[$item['icon']] ?><span><?= Html::encode($item['label']) ?></span>
                </a>
            <?php endforeach; ?>
            <?= Html::beginForm(['site/logout'], 'post') ?>
            <button type="submit" class="sheet__item" role="menuitem"><?= $icons['logout'] ?><span>Sair</span></button>
            <?= Html::endForm() ?>
        </div>
    </div>
<?php endif; ?>

<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>
```

- [ ] **Step 2: `views/site/index.php`** (dashboard stub com KPIs reais já disponíveis)

```php
<?php
/** @var yii\web\View $this */
/** @var int $obrasAtivas */
/** @var int $profissionaisAtivos */
$this->title = 'Início';
?>
<div class="kpi-grid">
    <div class="kpi"><div class="v"><?= $obrasAtivas ?></div><div class="l">Obras ativas</div></div>
    <div class="kpi"><div class="v"><?= $profissionaisAtivos ?></div><div class="l">Profissionais ativos</div></div>
</div>
<p class="em-breve">Diárias, gráficos e alertas chegam nos próximos módulos.</p>
```

- [ ] **Step 3: `views/site/em-breve.php`** (reutilizada pelos stubs)

```php
<?php
/** @var yii\web\View $this */
/** @var string $modulo */
$this->title = $modulo;
?>
<div class="em-breve">
    <h2><?= \yii\helpers\Html::encode($modulo) ?></h2>
    <p>Este módulo será entregue em um próximo spec.</p>
</div>
```

- [ ] **Step 4: Controllers stub**

`controllers/EfetivoController.php`:

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use yii\web\Controller;
use yii\filters\AccessControl;

class EfetivoController extends Controller
{
    public function behaviors(): array
    {
        return ['access' => ['class' => AccessControl::class, 'rules' => [['allow' => true, 'roles' => ['@']]]]];
    }

    public function actionIndex(): string
    {
        return $this->render('@app/views/site/em-breve', ['modulo' => 'Efetivo']);
    }

    public function actionCreate(): string
    {
        return $this->render('@app/views/site/em-breve', ['modulo' => 'Registrar efetivo']);
    }
}
```

`controllers/CardController.php` e `controllers/RelatorioController.php`: idênticos em estrutura, sem `actionCreate`, com `modulo` "Cards" e "Relatórios" respectivamente:

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use yii\web\Controller;
use yii\filters\AccessControl;

class CardController extends Controller
{
    public function behaviors(): array
    {
        return ['access' => ['class' => AccessControl::class, 'rules' => [['allow' => true, 'roles' => ['@']]]]];
    }

    public function actionIndex(): string
    {
        return $this->render('@app/views/site/em-breve', ['modulo' => 'Cards']);
    }
}
```

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use yii\web\Controller;
use yii\filters\AccessControl;

class RelatorioController extends Controller
{
    public function behaviors(): array
    {
        return ['access' => ['class' => AccessControl::class, 'rules' => [['allow' => true, 'roles' => ['@']]]]];
    }

    public function actionIndex(): string
    {
        return $this->render('@app/views/site/em-breve', ['modulo' => 'Relatórios']);
    }
}
```

- [ ] **Step 5: Verificação manual (logar e ver o shell)**

```bash
docker compose exec php php yii migrate --interactive=0   # garante seed admin
```
Abra `http://localhost:8082/login`, entre com `admin` / `citrus@2026` e confirme: topbar com logo, bottom-nav com FAB lime central, sheet "Mais" abrindo.

- [ ] **Step 6: Commit**

```bash
git add citrus/views/layouts/main.php citrus/views/site citrus/controllers/EfetivoController.php citrus/controllers/CardController.php citrus/controllers/RelatorioController.php
git commit -m "feat(citrus): app-shell mobile (bottom-nav + FAB + sheet) por perfil"
```

---

## Task 14: CRUD de Funções

**Files:**
- Create: `controllers/FuncaoController.php`, `models/FuncaoSearch.php`
- Create: `views/funcao/index.php`, `views/funcao/_form.php`, `views/funcao/_search.php`
- Test: `tests/functional/FuncaoCest.php`

- [ ] **Step 1: `models/FuncaoSearch.php`**

```php
<?php

namespace app\models;

use yii\data\ActiveDataProvider;

class FuncaoSearch extends Funcao
{
    public function rules()
    {
        return [
            [['nome'], 'safe'],
            [['tipo_remuneracao', 'status'], 'safe'],
        ];
    }

    public function scenarios()
    {
        return \yii\base\Model::scenarios();
    }

    public function search(array $params): ActiveDataProvider
    {
        $query = Funcao::find();
        $dataProvider = new ActiveDataProvider([
            'query' => $query,
            'sort' => ['defaultOrder' => ['nome' => SORT_ASC]],
        ]);
        $this->load($params);
        if (!$this->validate()) {
            return $dataProvider;
        }
        $query->andFilterWhere(['like', 'nome', $this->nome])
              ->andFilterWhere(['tipo_remuneracao' => $this->tipo_remuneracao])
              ->andFilterWhere(['status' => $this->status]);
        return $dataProvider;
    }
}
```

- [ ] **Step 2: `controllers/FuncaoController.php`** (CRUD + export, protegido por PermissaoBehavior)

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use Yii;
use app\models\Funcao;
use app\models\FuncaoSearch;
use app\components\PermissaoBehavior;
use app\components\ExportHelper;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\filters\VerbFilter;

class FuncaoController extends Controller
{
    public function behaviors(): array
    {
        return [
            'access' => ['class' => PermissaoBehavior::class],
            'verbs' => ['class' => VerbFilter::class, 'actions' => ['delete' => ['post']]],
        ];
    }

    public function actionIndex()
    {
        $searchModel = new FuncaoSearch();
        $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
        return $this->render('index', compact('searchModel', 'dataProvider'));
    }

    public function actionCreate()
    {
        $model = new Funcao(['tipo_remuneracao' => 'diaria', 'status' => 'ativo']);
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            Yii::$app->session->setFlash('success', 'Função cadastrada.');
            return $this->redirect(['index']);
        }
        return $this->render('_form', ['model' => $model]);
    }

    public function actionUpdate(int $id)
    {
        $model = $this->findModel($id);
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            Yii::$app->session->setFlash('success', 'Função atualizada.');
            return $this->redirect(['index']);
        }
        return $this->render('_form', ['model' => $model]);
    }

    public function actionDelete(int $id)
    {
        $this->findModel($id)->delete();
        Yii::$app->session->setFlash('success', 'Função removida.');
        return $this->redirect(['index']);
    }

    public function actionExport()
    {
        $rows = [];
        foreach (Funcao::find()->orderBy('nome')->all() as $f) {
            $rows[] = [$f->nome, $f->tipo_remuneracao, $f->valor_base_diaria, $f->valor_base_quinzena, $f->status];
        }
        return ExportHelper::download('funcoes',
            ['Nome', 'Tipo', 'Valor Diária', 'Valor Quinzena', 'Status'], $rows);
    }

    protected function findModel(int $id): Funcao
    {
        $model = Funcao::findOne($id);
        if (!$model) {
            throw new NotFoundHttpException('Função não encontrada.');
        }
        return $model;
    }
}
```

- [ ] **Step 3: `views/funcao/_form.php`** (com toggle de valor por tipo)

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\Funcao $model */

use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;

$this->title = $model->isNewRecord ? 'Nova Função' : 'Editar Função';
?>
<?php if (Yii::$app->session->hasFlash('error')): ?>
    <div class="alert alert-danger"><?= Yii::$app->session->getFlash('error') ?></div>
<?php endif; ?>

<?php $form = ActiveForm::begin(['id' => 'funcao-form']); ?>
    <?= $form->field($model, 'nome')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'tipo_remuneracao')->dropDownList([
        'diaria' => 'Por diária', 'quinzena' => 'Por quinzena',
    ]) ?>
    <?= $form->field($model, 'valor_base_diaria')->textInput(['type' => 'number', 'step' => '0.01']) ?>
    <?= $form->field($model, 'valor_base_quinzena')->textInput(['type' => 'number', 'step' => '0.01']) ?>
    <?= $form->field($model, 'status')->dropDownList(['ativo' => 'Ativo', 'inativo' => 'Inativo']) ?>
    <?= Html::submitButton('Salvar', ['class' => 'btn btn-lime w-100']) ?>
<?php ActiveForm::end(); ?>

<?php
$this->registerJs(<<<JS
function citrusToggleValor(){
  var t = document.getElementById('funcao-tipo_remuneracao').value;
  document.querySelector('.field-funcao-valor_base_diaria').style.display   = (t==='diaria')   ? '' : 'none';
  document.querySelector('.field-funcao-valor_base_quinzena').style.display = (t==='quinzena') ? '' : 'none';
}
document.getElementById('funcao-tipo_remuneracao').addEventListener('change', citrusToggleValor);
citrusToggleValor();
JS);
?>
```

- [ ] **Step 4: `views/funcao/_search.php` e `views/funcao/index.php`**

`views/funcao/_search.php`:

```php
<?php
/** @var app\models\FuncaoSearch $model */
use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;
$form = ActiveForm::begin(['method' => 'get', 'action' => ['index']]);
echo $form->field($model, 'nome')->textInput(['placeholder' => 'Buscar por nome']);
echo $form->field($model, 'status')->dropDownList(['' => 'Todos', 'ativo' => 'Ativo', 'inativo' => 'Inativo']);
echo Html::submitButton('Filtrar', ['class' => 'btn btn-lime']);
ActiveForm::end();
```

`views/funcao/index.php`:

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\FuncaoSearch $searchModel */
/** @var yii\data\ActiveDataProvider $dataProvider */

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

$this->title = 'Funções';
?>
<div class="d-flex justify-content-between align-items-center mb-2">
    <?= Html::a('+ Nova', ['create'], ['class' => 'btn btn-lime']) ?>
    <?= Html::a('Excel', ['export'], ['class' => 'btn btn-outline-secondary btn-sm']) ?>
</div>

<?= $this->render('_search', ['model' => $searchModel]) ?>

<div class="card-list">
    <?php foreach ($dataProvider->getModels() as $f): /** @var app\models\Funcao $f */ ?>
        <a class="entity-card" href="<?= Url::to(['update', 'id' => $f->id]) ?>">
            <div>
                <h3><?= Html::encode($f->nome) ?></h3>
                <div class="meta"><?= $f->tipo_remuneracao === 'diaria'
                    ? 'Diária R$ ' . number_format((float)$f->valor_base_diaria, 2, ',', '.')
                    : 'Quinzena R$ ' . number_format((float)$f->valor_base_quinzena, 2, ',', '.') ?></div>
            </div>
            <span class="badge-status<?= $f->status === 'ativo' ? '' : ' off' ?>"><?= ucfirst($f->status) ?></span>
        </a>
    <?php endforeach; ?>
    <?php if (!$dataProvider->getCount()): ?>
        <p class="em-breve">Nenhuma função cadastrada.</p>
    <?php endif; ?>
</div>
```

- [ ] **Step 5: Teste funcional `tests/functional/FuncaoCest.php`**

```php
<?php

class FuncaoCest
{
    public function _before(\FunctionalTester $I)
    {
        $I->haveFixtures([
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
        ]);
        // autentica como admin (id=1 da fixture)
        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
    }

    public function listaFuncoes(\FunctionalTester $I)
    {
        $I->amOnRoute('funcao/index');
        $I->see('Funções');
        $I->see('Pedreiro');
    }

    public function criaFuncaoDiaria(\FunctionalTester $I)
    {
        $I->amOnRoute('funcao/create');
        $I->submitForm('#funcao-form', [
            'Funcao[nome]' => 'Servente',
            'Funcao[tipo_remuneracao]' => 'diaria',
            'Funcao[valor_base_diaria]' => '120.00',
            'Funcao[status]' => 'ativo',
        ]);
        $I->seeRecord(\app\models\Funcao::class, ['nome' => 'Servente']);
    }
}
```

- [ ] **Step 6: Rodar testes**

```bash
docker compose exec php vendor/bin/codecept run functional FuncaoCest
```

Expected: PASS (2 cenários).

- [ ] **Step 7: Commit**

```bash
git add citrus/controllers/FuncaoController.php citrus/models/FuncaoSearch.php citrus/views/funcao citrus/tests/functional/FuncaoCest.php
git commit -m "feat(citrus): CRUD de funções com filtro e export"
```

---

## Task 15: CRUD de Profissionais (campos condicionais + valor pré-carregado)

**Files:**
- Create: `controllers/ProfissionalController.php`, `models/ProfissionalSearch.php`
- Create: `views/profissional/index.php`, `views/profissional/_form.php`, `views/profissional/_search.php`
- Create: `web/js/profissional-form.js`
- Test: `tests/functional/ProfissionalCest.php`

- [ ] **Step 1: `models/ProfissionalSearch.php`**

```php
<?php

namespace app\models;

use yii\base\Model;
use yii\data\ActiveDataProvider;

class ProfissionalSearch extends Profissional
{
    public function rules()
    {
        return [[['nome', 'cpf', 'funcao_id', 'status'], 'safe']];
    }

    public function scenarios()
    {
        return Model::scenarios();
    }

    public function search(array $params): ActiveDataProvider
    {
        $query = Profissional::find();
        $dp = new ActiveDataProvider(['query' => $query, 'sort' => ['defaultOrder' => ['nome' => SORT_ASC]]]);
        $this->load($params);
        if (!$this->validate()) {
            return $dp;
        }
        $query->andFilterWhere(['like', 'nome', $this->nome])
              ->andFilterWhere(['like', 'cpf', $this->cpf])
              ->andFilterWhere(['funcao_id' => $this->funcao_id])
              ->andFilterWhere(['status' => $this->status]);
        return $dp;
    }
}
```

- [ ] **Step 2: `controllers/ProfissionalController.php`**

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use Yii;
use app\models\Profissional;
use app\models\ProfissionalSearch;
use app\models\Funcao;
use app\components\PermissaoBehavior;
use app\components\ExportHelper;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\filters\VerbFilter;

class ProfissionalController extends Controller
{
    public function behaviors(): array
    {
        return [
            'access' => ['class' => PermissaoBehavior::class],
            'verbs' => ['class' => VerbFilter::class, 'actions' => ['delete' => ['post']]],
        ];
    }

    public function actionIndex()
    {
        $searchModel = new ProfissionalSearch();
        $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
        return $this->render('index', compact('searchModel', 'dataProvider'));
    }

    public function actionCreate()
    {
        $model = new Profissional(['status' => 'ativo']);
        return $this->salvar($model);
    }

    public function actionUpdate(int $id)
    {
        return $this->salvar($this->findModel($id));
    }

    private function salvar(Profissional $model)
    {
        if ($model->load(Yii::$app->request->post())) {
            // perfis vem como array do checkbox; converte para CSV da coluna SET
            $perfis = Yii::$app->request->post('Profissional')['perfisArray'] ?? [];
            $model->perfis = is_array($perfis) ? implode(',', $perfis) : '';
            if ($model->save()) {
                Yii::$app->session->setFlash('success', 'Profissional salvo.');
                return $this->redirect(['index']);
            }
        }
        $model->perfisArray = $model->getPerfisLista();
        return $this->render('_form', ['model' => $model, 'funcoes' => Funcao::ativas()]);
    }

    public function actionDelete(int $id)
    {
        $this->findModel($id)->delete();
        Yii::$app->session->setFlash('success', 'Profissional removido.');
        return $this->redirect(['index']);
    }

    /** Retorna o valor base da função (JSON) para pré-carregar o campo no form. */
    public function actionValorFuncao(int $id): Response
    {
        $f = Funcao::findOne($id);
        return $this->asJson(['valor' => $f ? (float) ($f->valor_base_diaria ?? 0) : 0]);
    }

    public function actionExport()
    {
        $rows = [];
        foreach (Profissional::find()->orderBy('nome')->all() as $p) {
            $rows[] = [$p->nome, $p->cpf, $p->funcao->nome ?? '', $p->perfis, $p->status];
        }
        return ExportHelper::download('profissionais',
            ['Nome', 'CPF', 'Função', 'Perfis', 'Status'], $rows);
    }

    protected function findModel(int $id): Profissional
    {
        $model = Profissional::findOne($id);
        if (!$model) {
            throw new NotFoundHttpException('Profissional não encontrado.');
        }
        return $model;
    }
}
```

- [ ] **Step 3: `web/js/profissional-form.js`** (mostra login/senha e bônus conforme perfis; pré-carrega valor da função)

```javascript
(function () {
  function has(perfil) {
    var el = document.querySelector('input[name="Profissional[perfisArray][]"][value="' + perfil + '"]');
    return el && el.checked;
  }
  function toggle() {
    var comAcesso = has('lider') || has('gerente') || has('administrativo');
    var loginBox = document.querySelector('.field-profissional-login');
    var senhaBox = document.querySelector('.field-profissional-senha');
    var bonusBox = document.querySelector('.field-profissional-valor_bonus_mensal');
    if (loginBox) loginBox.style.display = comAcesso ? '' : 'none';
    if (senhaBox) senhaBox.style.display = comAcesso ? '' : 'none';
    if (bonusBox) bonusBox.style.display = has('lider') ? '' : 'none';
  }
  document.querySelectorAll('input[name="Profissional[perfisArray][]"]').forEach(function (el) {
    el.addEventListener('change', toggle);
  });
  toggle();

  var func = document.getElementById('profissional-funcao_id');
  var valor = document.getElementById('profissional-valor_diaria');
  if (func && valor) {
    func.addEventListener('change', function () {
      if (!func.value) return;
      fetch('/profissional/valor-funcao/' + func.value)
        .then(function (r) { return r.json(); })
        .then(function (d) { if (!valor.value || valor.value === '0') valor.value = d.valor; });
    });
  }
})();
```

- [ ] **Step 4: `views/profissional/_form.php`**

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\Profissional $model */
/** @var app\models\Funcao[] $funcoes */

use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;
use yii\helpers\ArrayHelper;

$this->title = $model->isNewRecord ? 'Novo Profissional' : 'Editar Profissional';
$this->registerJsFile('@web/js/profissional-form.js', ['depends' => [\app\assets\AppAsset::class]]);
?>
<?php $form = ActiveForm::begin(['id' => 'profissional-form']); ?>
    <?= $form->field($model, 'nome')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'cpf')->textInput(['data-mask' => '###.###.###-##', 'maxlength' => 14]) ?>
    <?= $form->field($model, 'cnpj')->textInput(['data-mask' => '##.###.###/####-##', 'maxlength' => 18]) ?>
    <?= $form->field($model, 'razao_social')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'data_nascimento')->input('date') ?>
    <?= $form->field($model, 'endereco')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'whatsapp')->textInput(['data-mask' => '(##) #####-####', 'maxlength' => 20]) ?>
    <?= $form->field($model, 'email')->input('email') ?>
    <?= $form->field($model, 'funcao_id')->dropDownList(
        ArrayHelper::map($funcoes, 'id', 'nome'), ['prompt' => 'Selecione...']) ?>
    <?= $form->field($model, 'valor_diaria')->textInput(['type' => 'number', 'step' => '0.01']) ?>
    <?= $form->field($model, 'perfisArray')->checkboxList([
        'profissional' => 'Profissional', 'lider' => 'Líder',
        'gerente' => 'Gerente', 'administrativo' => 'Administrativo',
    ])->label('Perfis') ?>
    <?= $form->field($model, 'valor_bonus_mensal')->textInput(['type' => 'number', 'step' => '0.01']) ?>
    <?= $form->field($model, 'login')->textInput(['maxlength' => true, 'autocomplete' => 'off']) ?>
    <?= $form->field($model, 'senha')->passwordInput(['autocomplete' => 'new-password']) ?>
    <?= $form->field($model, 'status')->dropDownList([
        'ativo' => 'Ativo', 'inativo' => 'Inativo', 'afastado' => 'Afastado']) ?>
    <?= Html::submitButton('Salvar', ['class' => 'btn btn-lime w-100']) ?>
<?php ActiveForm::end(); ?>
```

- [ ] **Step 5: `views/profissional/_search.php` e `views/profissional/index.php`**

`views/profissional/_search.php`:

```php
<?php
/** @var app\models\ProfissionalSearch $model */
use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;
$form = ActiveForm::begin(['method' => 'get', 'action' => ['index']]);
echo $form->field($model, 'nome')->textInput(['placeholder' => 'Nome ou CPF']);
echo $form->field($model, 'status')->dropDownList(
    ['' => 'Todos', 'ativo' => 'Ativo', 'inativo' => 'Inativo', 'afastado' => 'Afastado']);
echo Html::submitButton('Filtrar', ['class' => 'btn btn-lime']);
ActiveForm::end();
```

`views/profissional/index.php`:

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\ProfissionalSearch $searchModel */
/** @var yii\data\ActiveDataProvider $dataProvider */

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

$this->title = 'Profissionais';
?>
<div class="d-flex justify-content-between align-items-center mb-2">
    <?= Html::a('+ Novo', ['create'], ['class' => 'btn btn-lime']) ?>
    <?= Html::a('Excel', ['export'], ['class' => 'btn btn-outline-secondary btn-sm']) ?>
</div>

<?= $this->render('_search', ['model' => $searchModel]) ?>

<div class="card-list">
    <?php foreach ($dataProvider->getModels() as $p): /** @var app\models\Profissional $p */ ?>
        <a class="entity-card" href="<?= Url::to(['update', 'id' => $p->id]) ?>">
            <div>
                <h3><?= Html::encode($p->nome) ?></h3>
                <div class="meta"><?= Html::encode($p->funcao->nome ?? '—') ?> · <?= Html::encode($p->cpf) ?></div>
            </div>
            <span class="badge-status<?= $p->status === 'ativo' ? '' : ' off' ?>"><?= ucfirst($p->status) ?></span>
        </a>
    <?php endforeach; ?>
    <?php if (!$dataProvider->getCount()): ?>
        <p class="em-breve">Nenhum profissional cadastrado.</p>
    <?php endif; ?>
</div>
```

- [ ] **Step 6: Teste funcional `tests/functional/ProfissionalCest.php`**

```php
<?php

class ProfissionalCest
{
    public function _before(\FunctionalTester $I)
    {
        $I->haveFixtures([
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
        ]);
        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
    }

    public function criaProfissionalSemAcesso(\FunctionalTester $I)
    {
        $I->amOnRoute('profissional/create');
        $I->submitForm('#profissional-form', [
            'Profissional[nome]' => 'Maria Pedreira',
            'Profissional[cpf]' => '333.333.333-33',
            'Profissional[data_nascimento]' => '1992-02-02',
            'Profissional[endereco]' => 'Rua C',
            'Profissional[whatsapp]' => '(11) 92222-2222',
            'Profissional[funcao_id]' => '10',
            'Profissional[valor_diaria]' => '150',
            'Profissional[perfisArray]' => ['profissional'],
            'Profissional[status]' => 'ativo',
        ]);
        $I->seeRecord(\app\models\Profissional::class, ['cpf' => '333.333.333-33']);
    }

    public function liderSemLoginFalha(\FunctionalTester $I)
    {
        $I->amOnRoute('profissional/create');
        $I->submitForm('#profissional-form', [
            'Profissional[nome]' => 'Pedro Líder',
            'Profissional[cpf]' => '444.444.444-44',
            'Profissional[data_nascimento]' => '1992-02-02',
            'Profissional[endereco]' => 'Rua D',
            'Profissional[whatsapp]' => '(11) 93333-3333',
            'Profissional[funcao_id]' => '10',
            'Profissional[valor_diaria]' => '150',
            'Profissional[perfisArray]' => ['lider'],
            'Profissional[status]' => 'ativo',
        ]);
        $I->see('Login é obrigatório');
    }
}
```

- [ ] **Step 7: Rodar testes**

```bash
docker compose exec php vendor/bin/codecept run functional ProfissionalCest
```

Expected: PASS (2 cenários).

- [ ] **Step 8: Commit**

```bash
git add citrus/controllers/ProfissionalController.php citrus/models/ProfissionalSearch.php citrus/views/profissional citrus/web/js/profissional-form.js citrus/tests/functional/ProfissionalCest.php
git commit -m "feat(citrus): CRUD de profissionais (campos condicionais + valor da função)"
```

---

## Task 16: CRUD de Obras (busca de CEP via ViaCEP + selects de líder)

**Files:**
- Create: `controllers/ObraController.php`, `models/ObraSearch.php`
- Create: `views/obra/index.php`, `views/obra/_form.php`, `views/obra/_search.php`
- Create: `web/js/cep.js`
- Test: `tests/functional/ObraCest.php`

- [ ] **Step 1: `models/Obra.php`**

```php
<?php

namespace app\models;

class Obra extends BaseActiveRecord
{
    public static function tableName()
    {
        return '{{%obra}}';
    }

    public function rules()
    {
        return [
            [['nome', 'cliente', 'turno', 'cep', 'bairro', 'endereco', 'numero', 'uf', 'cidade', 'status'], 'required'],
            [['nome', 'cliente', 'endereco'], 'string', 'max' => 255],
            [['turno'], 'string', 'max' => 60],
            [['cep'], 'string', 'max' => 9],
            [['bairro', 'cidade'], 'string', 'max' => 120],
            [['numero'], 'string', 'max' => 20],
            [['uf'], 'string', 'max' => 2],
            [['lider1_id', 'lider2_id'], 'integer'],
            [['lider1_id', 'lider2_id'], 'exist', 'targetClass' => Profissional::class, 'targetAttribute' => 'id', 'skipOnEmpty' => true],
            [['status'], 'in', 'range' => ['ativo', 'inativo']],
        ];
    }

    public function attributeLabels()
    {
        return [
            'nome' => 'Nome da Obra', 'cliente' => 'Cliente', 'turno' => 'Turno',
            'cep' => 'CEP', 'bairro' => 'Bairro', 'endereco' => 'Endereço', 'numero' => 'Número',
            'uf' => 'UF', 'cidade' => 'Cidade', 'lider1_id' => 'Líder Obra 1',
            'lider2_id' => 'Líder Obra 2', 'status' => 'Status',
        ];
    }

    public function getLider1()
    {
        return $this->hasOne(Profissional::class, ['id' => 'lider1_id']);
    }

    public function getLider2()
    {
        return $this->hasOne(Profissional::class, ['id' => 'lider2_id']);
    }
}
```

- [ ] **Step 2: `models/ObraSearch.php`**

```php
<?php

namespace app\models;

use yii\base\Model;
use yii\data\ActiveDataProvider;

class ObraSearch extends Obra
{
    public ?string $lider = null;

    public function rules()
    {
        return [[['nome', 'cliente', 'lider', 'status'], 'safe']];
    }

    public function scenarios()
    {
        return Model::scenarios();
    }

    public function search(array $params): ActiveDataProvider
    {
        $query = Obra::find();
        $dp = new ActiveDataProvider(['query' => $query, 'sort' => ['defaultOrder' => ['nome' => SORT_ASC]]]);
        $this->load($params);
        if (!$this->validate()) {
            return $dp;
        }
        $query->andFilterWhere(['like', 'nome', $this->nome])
              ->andFilterWhere(['like', 'cliente', $this->cliente])
              ->andFilterWhere(['status' => $this->status]);
        if ($this->lider) {
            $ids = Profissional::find()->select('id')
                ->where(['like', 'nome', $this->lider])->column();
            $query->andWhere(['or', ['lider1_id' => $ids], ['lider2_id' => $ids]]);
        }
        return $dp;
    }
}
```

- [ ] **Step 3: `web/js/cep.js`** (ViaCEP)

```javascript
(function () {
  var cep = document.getElementById('obra-cep');
  if (!cep) return;
  cep.addEventListener('blur', function () {
    var v = cep.value.replace(/\D/g, '');
    if (v.length !== 8) return;
    fetch('https://viacep.com.br/ws/' + v + '/json/')
      .then(function (r) { return r.json(); })
      .then(function (d) {
        if (d.erro) return;
        var set = function (id, val) { var el = document.getElementById(id); if (el && !el.value) el.value = val || ''; };
        set('obra-bairro', d.bairro);
        set('obra-endereco', d.logradouro);
        set('obra-cidade', d.localidade);
        set('obra-uf', d.uf);
      });
  });
})();
```

- [ ] **Step 4: `controllers/ObraController.php`**

```php
<?php

declare(strict_types=1);

namespace app\controllers;

use Yii;
use app\models\Obra;
use app\models\ObraSearch;
use app\models\Profissional;
use app\components\PermissaoBehavior;
use app\components\ExportHelper;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\filters\VerbFilter;

class ObraController extends Controller
{
    public function behaviors(): array
    {
        return [
            'access' => ['class' => PermissaoBehavior::class],
            'verbs' => ['class' => VerbFilter::class, 'actions' => ['delete' => ['post']]],
        ];
    }

    public function actionIndex()
    {
        $searchModel = new ObraSearch();
        $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
        return $this->render('index', compact('searchModel', 'dataProvider'));
    }

    public function actionCreate()
    {
        $model = new Obra(['status' => 'ativo']);
        return $this->salvar($model);
    }

    public function actionUpdate(int $id)
    {
        return $this->salvar($this->findModel($id));
    }

    private function salvar(Obra $model)
    {
        if ($model->load(Yii::$app->request->post()) && $model->save()) {
            Yii::$app->session->setFlash('success', 'Obra salva.');
            return $this->redirect(['index']);
        }
        return $this->render('_form', ['model' => $model, 'lideres' => Profissional::comPerfil('lider')]);
    }

    public function actionDelete(int $id)
    {
        $this->findModel($id)->delete();
        Yii::$app->session->setFlash('success', 'Obra removida.');
        return $this->redirect(['index']);
    }

    public function actionExport()
    {
        $rows = [];
        foreach (Obra::find()->orderBy('nome')->all() as $o) {
            $rows[] = [$o->nome, $o->cliente, $o->turno, $o->lider1->nome ?? '', $o->lider2->nome ?? '', $o->status];
        }
        return ExportHelper::download('obras',
            ['Obra', 'Cliente', 'Turno', 'Líder 1', 'Líder 2', 'Status'], $rows);
    }

    protected function findModel(int $id): Obra
    {
        $model = Obra::findOne($id);
        if (!$model) {
            throw new NotFoundHttpException('Obra não encontrada.');
        }
        return $model;
    }
}
```

- [ ] **Step 5: `views/obra/_form.php`**

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\Obra $model */
/** @var app\models\Profissional[] $lideres */

use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;
use yii\helpers\ArrayHelper;

$this->title = $model->isNewRecord ? 'Nova Obra' : 'Editar Obra';
$this->registerJsFile('@web/js/cep.js', ['depends' => [\app\assets\AppAsset::class]]);
$ufs = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'];
?>
<?php $form = ActiveForm::begin(['id' => 'obra-form']); ?>
    <?= $form->field($model, 'nome')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'cliente')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'turno')->textInput(['placeholder' => 'Diurno, Noturno, Misto...']) ?>
    <?= $form->field($model, 'cep')->textInput(['data-mask' => '#####-###', 'maxlength' => 9]) ?>
    <?= $form->field($model, 'bairro')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'endereco')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'numero')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'cidade')->textInput(['maxlength' => true]) ?>
    <?= $form->field($model, 'uf')->dropDownList(array_combine($ufs, $ufs), ['prompt' => 'UF']) ?>
    <?= $form->field($model, 'lider1_id')->dropDownList(
        ArrayHelper::map($lideres, 'id', 'nome'), ['prompt' => 'Selecione...']) ?>
    <?= $form->field($model, 'lider2_id')->dropDownList(
        ArrayHelper::map($lideres, 'id', 'nome'), ['prompt' => 'Selecione...']) ?>
    <?= $form->field($model, 'status')->dropDownList(['ativo' => 'Ativo', 'inativo' => 'Inativo']) ?>
    <?= Html::submitButton('Salvar', ['class' => 'btn btn-lime w-100']) ?>
<?php ActiveForm::end(); ?>
```

- [ ] **Step 6: `views/obra/_search.php` e `views/obra/index.php`**

`views/obra/_search.php`:

```php
<?php
/** @var app\models\ObraSearch $model */
use yii\bootstrap5\ActiveForm;
use yii\bootstrap5\Html;
$form = ActiveForm::begin(['method' => 'get', 'action' => ['index']]);
echo $form->field($model, 'nome')->textInput(['placeholder' => 'Nome da obra']);
echo $form->field($model, 'cliente')->textInput(['placeholder' => 'Cliente']);
echo $form->field($model, 'lider')->textInput(['placeholder' => 'Líder']);
echo Html::submitButton('Filtrar', ['class' => 'btn btn-lime']);
ActiveForm::end();
```

`views/obra/index.php`:

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\ObraSearch $searchModel */
/** @var yii\data\ActiveDataProvider $dataProvider */

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

$this->title = 'Obras';
?>
<div class="d-flex justify-content-between align-items-center mb-2">
    <?= Html::a('+ Nova', ['create'], ['class' => 'btn btn-lime']) ?>
    <?= Html::a('Excel', ['export'], ['class' => 'btn btn-outline-secondary btn-sm']) ?>
</div>

<?= $this->render('_search', ['model' => $searchModel]) ?>

<div class="card-list">
    <?php foreach ($dataProvider->getModels() as $o): /** @var app\models\Obra $o */ ?>
        <a class="entity-card" href="<?= Url::to(['update', 'id' => $o->id]) ?>">
            <div>
                <h3><?= Html::encode($o->nome) ?></h3>
                <div class="meta"><?= Html::encode($o->cliente) ?> · <?= Html::encode($o->cidade) ?>/<?= Html::encode($o->uf) ?></div>
            </div>
            <span class="badge-status<?= $o->status === 'ativo' ? '' : ' off' ?>"><?= ucfirst($o->status) ?></span>
        </a>
    <?php endforeach; ?>
    <?php if (!$dataProvider->getCount()): ?>
        <p class="em-breve">Nenhuma obra cadastrada.</p>
    <?php endif; ?>
</div>
```

- [ ] **Step 7: Teste funcional `tests/functional/ObraCest.php`**

```php
<?php

class ObraCest
{
    public function _before(\FunctionalTester $I)
    {
        $I->haveFixtures([
            'funcao' => \app\tests\fixtures\FuncaoFixture::class,
            'profissional' => \app\tests\fixtures\ProfissionalFixture::class,
        ]);
        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
    }

    public function criaObra(\FunctionalTester $I)
    {
        $I->amOnRoute('obra/create');
        $I->submitForm('#obra-form', [
            'Obra[nome]' => 'Edifício Aurora',
            'Obra[cliente]' => 'Construtora X',
            'Obra[turno]' => 'Diurno',
            'Obra[cep]' => '01001-000',
            'Obra[bairro]' => 'Sé',
            'Obra[endereco]' => 'Praça da Sé',
            'Obra[numero]' => '100',
            'Obra[cidade]' => 'São Paulo',
            'Obra[uf]' => 'SP',
            'Obra[status]' => 'ativo',
        ]);
        $I->seeRecord(\app\models\Obra::class, ['nome' => 'Edifício Aurora']);
    }
}
```

- [ ] **Step 8: Rodar testes**

```bash
docker compose exec php vendor/bin/codecept run functional ObraCest
```

Expected: PASS.

- [ ] **Step 9: Commit**

```bash
git add citrus/controllers/ObraController.php citrus/models/Obra.php citrus/models/ObraSearch.php citrus/views/obra citrus/web/js/cep.js citrus/tests/functional/ObraCest.php
git commit -m "feat(citrus): CRUD de obras com busca de CEP (ViaCEP) e líderes"
```

---

## Task 17: Verificação final e suíte completa

**Files:** nenhum novo — validação de regressão.

- [ ] **Step 1: Rodar a suíte inteira**

```bash
docker compose exec php vendor/bin/codecept run
```

Expected: todos os testes Unit + functional PASS.

- [ ] **Step 2: Análise estática e estilo**

```bash
docker compose exec php vendor/bin/phpstan analyse models components controllers --level=5 --no-progress || true
docker compose exec php vendor/bin/phpcs --standard=phpcs.xml.dist models components controllers || true
```

Expected: sem erros relevantes (ajustar pontualmente se houver).

- [ ] **Step 3: Smoke test manual do fluxo completo**

Logar como `admin`/`citrus@2026` e verificar: criar Função → criar Profissional (com e sem perfil de acesso) → criar Obra (CEP preenche endereço) → exportar cada listagem para Excel → abrir "Mais" → Sair.

- [ ] **Step 4: Commit final (se houver ajustes)**

```bash
git add citrus
git commit -m "chore(citrus): ajustes finais da fundação + cadastros"
```

---

## Notas de fechamento

- **Próximos specs** (não cobertos aqui): Efetivo (GPS + foto), Fechamento de Card (PDF), Dashboard (KPIs/gráficos/alertas), Relatórios dinâmicos. Os controllers `efetivo`/`card`/`relatorio` já existem como stubs e suas permissões já estão semeadas.
- **Senha do admin** semeada (`citrus@2026`) deve ser trocada após o primeiro acesso.
- **`cookieValidationKey`** está fixa em `web.php`; antes de ir a produção, troque por um valor próprio.
