# Fechamento — Ajustes v1 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:** Aplicar os 10 ajustes do lote "fechamento v1" — lançamento de efetivo (turno noturno, saída retroativa, dobra, líder visível), UX de bônus/descontos, tipagem de documentos e relatório/filtro de NF, e troca do CAPTCHA por reCAPTCHA v3.

**Architecture:** Projeto Yii2 (yii2-app-basic) sobre PHP 8.2 + MySQL 8, tudo dentro do container `php`. Models de negócio estendem `app\models\BaseActiveRecord` (soft-delete, timestamps, auditoria). Regras de negócio ficam nos models (validators), controllers seguem o padrão `FuncaoController`. Relatórios são SQL seedados na tabela `relatorio` e executados por `RelatorioRunner`. Testes em Codeception (suítes `Unit` e `Functional`).

**Tech Stack:** PHP 8.2, Yii2, MySQL 8, Bootstrap 5, Codeception, mPDF, reCAPTCHA v3.

**Convenções de comando (sempre no container):**
- Testes unit: `docker compose exec php vendor/bin/codecept run Unit models/<Arquivo>` (um método: `.../<Arquivo>:testMetodo`)
- Testes functional: `docker compose exec php vendor/bin/codecept run Functional <Arquivo>`
- Migrations: `docker compose exec php ./yii migrate --interactive=0`
- **Banco de teste:** o suite `Functional` usa `citrus_test`. Aplique as migrations nele do mesmo modo que o projeto já faz para as migrations existentes (ver README/CLAUDE.md) antes de rodar functional que dependa das colunas novas.

**Ordem de dependência:** C1 antes de C2/C3/C4 (a coluna `tipo` e as constantes precisam existir). D1 antes de D2. Os workstreams A e B são independentes.

---

## Workstream A · Lançamento de efetivo (itens 1–4)

Arquivos:
- Modify: `models/Efetivo.php`
- Modify: `controllers/EfetivoController.php:85-103` (`actionBuscarProfissional`)
- Test: `tests/unit/models/EfetivoTest.php`

Contexto do model atual (`models/Efetivo.php`): `rules()` tem hoje as linhas
`['hora_saida', 'validateSaidaAposEntrada']` e `['profissional_id', 'validateUnicoNoDia']`,
e os métodos `validateSaidaAposEntrada()` e `validateUnicoNoDia()`.

### Task A1: Item 1 — Turno noturno (permitir saída antes da entrada)

**Files:**
- Modify: `models/Efetivo.php`
- Test: `tests/unit/models/EfetivoTest.php:42-47`

- [ ] **Step 1: Ajustar o teste existente para o novo comportamento**

Em `tests/unit/models/EfetivoTest.php`, substitua o método `testSaidaAntesDaEntradaInvalida` (linhas 42–47) por:

```php
    public function testSaidaAntesDaEntradaAgoraValidaTurnoNoturno()
    {
        // Turno noturno: entra 22:00, sai 06:00 (dia seguinte). Deve ser válido.
        $e = $this->linha(['hora_entrada' => '22:00', 'hora_saida' => '06:00']);
        $this->assertTrue($e->validate(), 'saída antes da entrada é válida (turno noturno)');
    }
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/EfetivoTest:testSaidaAntesDaEntradaAgoraValidaTurnoNoturno`
Expected: FAIL — hoje `validateSaidaAposEntrada` adiciona erro em `hora_saida`.

- [ ] **Step 3: Remover a validação de saída-após-entrada**

Em `models/Efetivo.php`, na `rules()`, remova a linha:

```php
            ['hora_saida', 'validateSaidaAposEntrada'],
```

E remova o método inteiro:

```php
    public function validateSaidaAposEntrada($attribute, $params): void
    {
        if ($this->hora_saida && $this->hora_entrada
            && strtotime($this->hora_saida) < strtotime($this->hora_entrada)) {
            $this->addError($attribute, 'A saída não pode ser antes da entrada.');
        }
    }
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/EfetivoTest:testSaidaAntesDaEntradaAgoraValidaTurnoNoturno`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add models/Efetivo.php tests/unit/models/EfetivoTest.php
git commit -m "feat(efetivo): permite saida antes da entrada (turno noturno)"
```

### Task A2: Item 2 — Data retroativa exige hora de saída

**Files:**
- Modify: `models/Efetivo.php`
- Test: `tests/unit/models/EfetivoTest.php`

- [ ] **Step 1: Apontar o helper `linha()` para hoje**

O helper `linha()` (linhas 20–27) usa a data fixa `'2026-06-18'`, que é passada em relação a hoje. Como esta task torna a saída obrigatória em dias passados, os testes existentes que salvam sem saída passariam a falhar. Troque a data por hoje. Substitua:

```php
        $dia = EfetivoDia::findOrCreate(100, '2026-06-18');
```

por:

```php
        $dia = EfetivoDia::findOrCreate(100, date('Y-m-d'));
```

(Os testes que precisam de data passada/futura já criam o próprio `EfetivoDia` explicitamente.)

- [ ] **Step 2: Escrever os testes que falham**

Adicione em `tests/unit/models/EfetivoTest.php`:

```php
    public function testRetroativoExigeSaida()
    {
        $ontem = date('Y-m-d', strtotime('-1 day'));
        $dia = EfetivoDia::findOrCreate(100, $ontem);
        $e = new Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']);
        $this->assertFalse($e->validate(), 'dia anterior sem saída deve reprovar');
        $this->assertArrayHasKey('hora_saida', $e->errors);
    }

    public function testRetroativoComSaidaValido()
    {
        $ontem = date('Y-m-d', strtotime('-1 day'));
        $dia = EfetivoDia::findOrCreate(100, $ontem);
        $e = new Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => 2,
            'hora_entrada' => '08:00', 'hora_saida' => '17:00']);
        $this->assertTrue($e->validate(), 'dia anterior com saída deve validar');
    }

    public function testHojeSaidaOpcional()
    {
        $hoje = date('Y-m-d');
        $dia = EfetivoDia::findOrCreate(100, $hoje);
        $e = new Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']);
        $this->assertTrue($e->validate(), 'lançamento de hoje pode ficar sem saída');
    }
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/EfetivoTest:testRetroativoExigeSaida`
Expected: FAIL — hoje não há validação de saída obrigatória.

- [ ] **Step 4: Adicionar o validator**

Em `models/Efetivo.php`, na `rules()`, adicione a linha (logo após a regra `match` de horários):

```php
            ['hora_saida', 'validateSaidaObrigatoriaRetroativa'],
```

E adicione o método:

```php
    /** Em dias anteriores a hoje já se sabe o horário de saída — torna-o obrigatório. */
    public function validateSaidaObrigatoriaRetroativa($attribute, $params): void
    {
        if ($this->hora_saida) {
            return;
        }
        $dia = $this->efetivoDia ?: EfetivoDia::findOne($this->efetivo_dia_id);
        if ($dia && $dia->data < date('Y-m-d')) {
            $this->addError($attribute, 'Informe a hora de saída para lançamentos de dias anteriores.');
        }
    }
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/EfetivoTest:testRetroativoExigeSaida models/EfetivoTest:testRetroativoComSaidaValido models/EfetivoTest:testHojeSaidaOpcional`
Expected: PASS (os três)

- [ ] **Step 6: Commit**

```bash
git add models/Efetivo.php tests/unit/models/EfetivoTest.php
git commit -m "feat(efetivo): exige hora de saida em lancamento retroativo"
```

### Task A3: Itens 3+4 — Dobra no mesmo dia com trava de horário e líder visível

**Files:**
- Modify: `models/Efetivo.php` (troca `validateUnicoNoDia` por `validateDobraPermitida`)
- Modify: `controllers/EfetivoController.php:85-103`
- Test: `tests/unit/models/EfetivoTest.php`

- [ ] **Step 1: Reescrever os testes de duplicidade + adicionar os de dobra**

Em `tests/unit/models/EfetivoTest.php`, substitua `testNaoDuplicaProfissionalNoDia` (linhas 34–40) e `testNaoDuplicaProfissionalEmOutraObraNoMesmoDia` (linhas 49–59) por:

```php
    public function testBloqueiaSegundaEntradaComPrimeiraAberta()
    {
        // 1ª entrada sem saída (aberta) → não pode abrir a 2ª.
        $this->assertTrue($this->linha(['hora_entrada' => '08:00'])->save());
        $dup = $this->linha(['hora_entrada' => '18:00']);
        $this->assertFalse($dup->validate());
        $this->assertArrayHasKey('hora_entrada', $dup->errors);
    }

    public function testBloqueiaSegundaEntradaAntesDaSaidaAnterior()
    {
        $this->assertTrue($this->linha(['hora_entrada' => '08:00', 'hora_saida' => '17:00'])->save());
        $dup = $this->linha(['hora_entrada' => '16:00', 'hora_saida' => '20:00']);
        $this->assertFalse($dup->validate());
        $this->assertArrayHasKey('hora_entrada', $dup->errors);
    }

    public function testPermiteDobraAposSaidaAnterior()
    {
        $this->assertTrue($this->linha(['hora_entrada' => '08:00', 'hora_saida' => '17:00'])->save());
        $dup = $this->linha(['hora_entrada' => '17:00', 'hora_saida' => '22:00']);
        $this->assertTrue($dup->validate(), 'dobra a partir da saída anterior é válida');
    }

    public function testBloqueiaDobraEmOutraObraComEntradaAberta()
    {
        $diaA = EfetivoDia::findOrCreate(100, '2026-09-10');
        $a = new Efetivo(['efetivo_dia_id' => $diaA->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']);
        $this->assertTrue($a->save());

        $diaB = EfetivoDia::findOrCreate(101, '2026-09-10'); // obra diferente, mesmo dia, 1ª ainda aberta
        $b = new Efetivo(['efetivo_dia_id' => $diaB->id, 'profissional_id' => 2, 'hora_entrada' => '18:00']);
        $this->assertFalse($b->validate());
        $this->assertArrayHasKey('hora_entrada', $b->errors);
    }
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/EfetivoTest:testPermiteDobraAposSaidaAnterior`
Expected: FAIL — hoje `validateUnicoNoDia` bloqueia qualquer 2º lançamento no dia.

- [ ] **Step 3: Trocar o validator no model**

Em `models/Efetivo.php`, na `rules()`, troque a linha:

```php
            ['profissional_id', 'validateUnicoNoDia'],
```

por:

```php
            ['hora_entrada', 'validateDobraPermitida'],
```

E substitua o método `validateUnicoNoDia()` inteiro por:

```php
    /** Regra de dobra: a mesma pessoa pode ter mais de uma entrada no dia (qualquer obra)
     *  desde que toda entrada anterior tenha saída e a nova entrada seja ≥ à última saída. */
    public function validateDobraPermitida($attribute, $params): void
    {
        $dia = $this->efetivoDia ?: EfetivoDia::findOne($this->efetivo_dia_id);
        if (!$dia) {
            return;
        }
        $existentes = Efetivo::find()
            ->joinWith('efetivoDia')
            ->where(['efetivo.profissional_id' => $this->profissional_id, 'efetivo_dia.data' => $dia->data])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->andWhere(['<>', 'efetivo.id', (int) $this->id])
            ->all();
        if (!$existentes) {
            return;
        }
        $maxSaida = null;
        foreach ($existentes as $e) {
            if (!$e->hora_saida) {
                $this->addError($attribute, 'Há um lançamento em aberto (sem saída) deste profissional neste dia. '
                    . 'Registre a saída antes de lançar uma nova entrada.');
                return;
            }
            if ($maxSaida === null || strtotime($e->hora_saida) > strtotime($maxSaida)) {
                $maxSaida = $e->hora_saida;
            }
        }
        if ($maxSaida !== null && strtotime((string) $this->hora_entrada) < strtotime($maxSaida)) {
            $this->addError($attribute,
                'A nova entrada deve ser a partir de ' . substr($maxSaida, 0, 5) . ' (após a saída anterior).');
        }
    }
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/EfetivoTest`
Expected: PASS (todos os métodos do arquivo)

- [ ] **Step 5: Ajustar o dropdown no controller (item 3)**

Em `controllers/EfetivoController.php`, dentro de `actionBuscarProfissional` (linhas 85–103), troque o bloco `$jaLancados`:

```php
        // profissionais com efetivo ativo nessa DATA em qualquer obra (não podem ser lançados de novo)
        $jaLancados = \app\models\Efetivo::find()
            ->joinWith('efetivoDia')
            ->where(['efetivo_dia.data' => $data])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->select('efetivo.profissional_id')->column();
```

por (excluir apenas quem tem entrada **em aberto** naquele dia — sobre um turno aberto não se pode dobrar):

```php
        // Só oculta quem tem uma entrada EM ABERTO (sem saída) nessa DATA — sobre um turno aberto
        // não se pode dobrar. Quem já fechou o ponto (inclusive líderes de outra obra) volta a aparecer.
        $jaLancados = \app\models\Efetivo::find()
            ->joinWith('efetivoDia')
            ->where(['efetivo_dia.data' => $data])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->andWhere(['efetivo.hora_saida' => null])
            ->select('efetivo.profissional_id')->column();
```

(O restante de `actionBuscarProfissional` — o `->andWhere(['not in', 'id', $jaLancados ?: [0]])` — permanece igual.)

- [ ] **Step 6: Rodar a suíte functional do efetivo para garantir que nada quebrou**

Run: `docker compose exec php vendor/bin/codecept run Functional EfetivoCest`
Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add models/Efetivo.php controllers/EfetivoController.php tests/unit/models/EfetivoTest.php
git commit -m "feat(efetivo): permite dobra no mesmo dia com trava de horario e mostra lider ja fechado"
```

---

## Workstream B · Bônus/Descontos no card (itens 5–6)

### Task B1: Item 5 — Botão de anexo do desconto clicável

**Files:**
- Modify: `web/css/app.css:277-291`
- Modify (se necessário): `views/card/fechar.php:105-112`

Contexto: `.card-item-row` é `display:flex;flex-wrap:wrap`. Na linha de desconto,
entre `.ci-valor` e `.ci-docs` existe o `<label class="card-nota-check">` ("Abate nota").
Em telas estreitas (mobile-first) o `.card-nota-check` (nowrap, largo) empurra o
`.ci-docs` (que usa `margin-left:auto`) para uma quebra onde o botão fica fora de
alcance/encoberto. O bônus não tem esse label, por isso funciona.

- [ ] **Step 1: Reproduzir o bug no navegador**

Use a skill `run` (ou suba o app: `docker compose up -d` → http://localhost:8082) e abra um card em rascunho com um desconto **já salvo**. Numa largura de celular (~390px, DevTools responsivo), confirme que o clip do desconto não abre o modal, enquanto o do bônus abre. Anote o comportamento real (botão encoberto x fora da tela x modal ausente) para confirmar a causa antes de corrigir.

- [ ] **Step 2: Aplicar o ajuste de layout**

Em `web/css/app.css`, substitua o bloco `.card-item-row` (linhas 277–281) por uma versão que garante o alvo de toque das ações e evita que o `card-nota-check` roube o espaço do `.ci-docs`:

```css
.card-item-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.card-item-row .ci-desc{flex:1 1 200px;min-width:0}
.card-item-row .ci-valor{flex:0 0 130px;text-align:right;font-variant-numeric:tabular-nums}
.card-item-row .ci-nota-spacer{flex:0 0 0;width:0;height:0}
.card-item-row .ci-docs{flex:0 0 auto;margin-left:auto;display:inline-flex;gap:6px;position:relative;z-index:1}
.card-item-row .ci-docs .icon-btn{min-width:38px;min-height:38px}
.card-item-row .card-nota-check{order:3}
.card-item-row .ci-docs{order:4}
```

Isto fixa a ordem visual (nota-check antes das ações), garante alvo de toque de 38px e mantém o `.ci-docs` acima na pilha. (Se o Step 1 revelar que a causa é outra — ex.: modal não gerado —, ajuste conforme o achado; o modal de item já é gerado em `views/card/fechar.php:184` para bônus **e** desconto.)

- [ ] **Step 3: Verificar no navegador**

Recarregue o card (mesma largura mobile). Confirme que o clip do desconto salvo agora abre o modal "Anexar comprovante" e que dá para escolher o arquivo. Confirme que o bônus continua funcionando.

- [ ] **Step 4: Commit**

```bash
git add web/css/app.css views/card/fechar.php
git commit -m "fix(card): botao de anexo do desconto clicavel em telas estreitas"
```

### Task B2: Item 6 — Validação de bônus/desconto meio-preenchido antes de salvar

**Files:**
- Modify: `web/js/card.js`

Contexto: `web/js/card.js` já tem `var form = document.getElementById('card-form')`
e um handler de `input`/`change` (`recompute`). As linhas ficam em
`.card-itens[data-tipo="bonus"|"desconto"] .card-item-row`, com `.ci-desc` (descrição)
e `.js-money` (valor). Hoje o servidor descarta linhas meio-preenchidas em silêncio.

- [ ] **Step 1: Adicionar validação de submit no card.js**

Em `web/js/card.js`, logo após a definição de `qtdVal` (antes de `function recompute()`), adicione o helper e o handler de submit:

```javascript
  // Bloqueia o submit se alguma linha de bônus/desconto tiver só um dos campos preenchido.
  function validarItens() {
    var pendente = null;
    form.querySelectorAll('.card-itens .card-item-row').forEach(function (row) {
      if (pendente) return;
      var desc = row.querySelector('.ci-desc');
      var val = row.querySelector('.js-money');
      if (!desc || !val) return;
      var temDesc = (desc.value || '').trim() !== '';
      var temVal = moneyVal(val) > 0;
      if (temDesc !== temVal) { pendente = row; }   // exatamente um preenchido
    });
    return pendente;
  }

  form.addEventListener('submit', function (ev) {
    var row = validarItens();
    if (row) {
      ev.preventDefault();
      var desc = row.querySelector('.ci-desc');
      var val = row.querySelector('.js-money');
      var alvo = (desc && (desc.value || '').trim() === '') ? desc : val;
      alert('Preencha descrição e valor, ou limpe a linha de bônus/desconto.');
      if (alvo) { alvo.focus(); }
    }
  });
```

- [ ] **Step 2: Verificar no navegador**

Suba o app, abra um card em rascunho. Em Bônus clique "+ adicionar", preencha **só o valor** (sem descrição) e clique "Salvar". Confirme: aparece o alerta, o submit é bloqueado e o foco vai para o campo de descrição. Repita preenchendo **só a descrição**. Depois preencha os dois e confirme que salva normalmente; deixe uma linha totalmente vazia e confirme que ela é ignorada sem alerta.

- [ ] **Step 3: Commit**

```bash
git add web/js/card.js
git commit -m "feat(card): avisa antes de salvar bonus/desconto meio-preenchido"
```

---

## Workstream C · NF / documentos (itens 7, 8, 10)

### Task C1: Item 7 (model) — Coluna `tipo` e constantes em Documento

**Files:**
- Create: `migrations/m260714_000002_add_tipo_to_documento_table.php`
- Modify: `models/Documento.php`
- Test: `tests/unit/models/DocumentoTest.php` (novo)

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

Crie `migrations/m260714_000002_add_tipo_to_documento_table.php`:

```php
<?php

use yii\db\Migration;

class m260714_000002_add_tipo_to_documento_table extends Migration
{
    public function safeUp()
    {
        // Categoria só usada em documentos de pagamento: 'nf' | 'comprovante' | 'outro'.
        $this->addColumn('{{%documento}}', 'tipo', $this->string(20)->null()->after('owner_id'));
    }

    public function safeDown()
    {
        $this->dropColumn('{{%documento}}', 'tipo');
    }
}
```

- [ ] **Step 2: Aplicar a migration (dev e teste)**

Run (dev DB): `docker compose exec -T php sh -lc './yii migrate --interactive=0'`
Run (test DB): `docker compose exec -T php sh -lc 'php tests/bin/yii migrate --interactive=0'`
Expected: ambos aplicam `m260714_000002_add_tipo_to_documento_table`. O banco de teste PRECISA da coluna, senão nem dá pra atribuir `tipo` no model (o AR carrega o schema do banco).

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

Crie `tests/unit/models/DocumentoTest.php`:

```php
<?php

namespace app\tests\Unit\Models;

use app\models\Documento;

class DocumentoTest extends \Codeception\Test\Unit
{
    private function pagamento(array $over = []): Documento
    {
        return new Documento(array_merge([
            'owner_tipo' => Documento::OWNER_CARD_PAGAMENTO,
            'owner_id' => 1,
            'arquivo' => 'x.pdf',
        ], $over));
    }

    public function testTipoValidoEmPagamento()
    {
        $this->assertTrue($this->pagamento(['tipo' => Documento::TIPO_NF])->validate());
        $this->assertTrue($this->pagamento(['tipo' => Documento::TIPO_COMPROVANTE])->validate());
        $this->assertTrue($this->pagamento(['tipo' => Documento::TIPO_OUTRO])->validate());
    }

    public function testTipoInvalidoEmPagamentoReprova()
    {
        $d = $this->pagamento(['tipo' => 'boleto']);
        $this->assertFalse($d->validate());
        $this->assertArrayHasKey('tipo', $d->errors);
    }

    public function testItemDeCardSemTipoContinuaValido()
    {
        $d = new Documento(['owner_tipo' => Documento::OWNER_CARD_ITEM, 'owner_id' => 1, 'arquivo' => 'x.pdf']);
        $this->assertTrue($d->validate(), 'documento de item não exige tipo');
    }
}
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/DocumentoTest:testTipoValidoEmPagamento`
Expected: FAIL — as constantes `TIPO_*` ainda não existem (erro de constante indefinida).

- [ ] **Step 5: Adicionar constantes e validação no model**

Em `models/Documento.php`, adicione após as constantes `OWNER_*`:

```php
    public const TIPO_NF = 'nf';
    public const TIPO_COMPROVANTE = 'comprovante';
    public const TIPO_OUTRO = 'outro';

    /** Rótulos das categorias de documento de pagamento. */
    public const TIPOS = [
        self::TIPO_NF => 'NF',
        self::TIPO_COMPROVANTE => 'Comprovante',
        self::TIPO_OUTRO => 'Outro',
    ];
```

E na `rules()`, adicione (dentro do array retornado):

```php
            [['tipo'], 'string', 'max' => 20],
            [['tipo'], 'in', 'range' => array_keys(self::TIPOS),
                'when' => fn($model) => $model->owner_tipo === self::OWNER_CARD_PAGAMENTO,
                'message' => 'Tipo de documento inválido.'],
```

E adicione o helper de rótulo ao final da classe:

```php
    public function tipoLabel(): string
    {
        return self::TIPOS[$this->tipo] ?? '';
    }
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit models/DocumentoTest`
Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add -A migrations/ models/Documento.php tests/
git commit -m "feat(documento): categoria (NF/Comprovante/Outro) em documentos de pagamento"
```

### Task C2: Item 7 (UI) — Selecionar tipo ao anexar e exibir badge

**Files:**
- Modify: `controllers/CardController.php:183-213` (`actionUploadDoc`)
- Modify: `views/card/fechar.php:145-164` (lista de docs) e `:208-230` (modal `up-pagto`)

- [ ] **Step 1: Gravar o tipo no upload de pagamento**

Em `controllers/CardController.php`, dentro de `actionUploadDoc`, no loop que salva os arquivos (linhas ~202–209), passe o `tipo` quando for documento de pagamento. Substitua:

```php
        $files = UploadedFile::getInstancesByName('documentos');
        $salvos = 0;
        foreach ($files as $file) {
            $info = DocumentoHelper::salvarComoPdf($file, $this->dirDocs());
            if ($info && (new Documento(array_merge($info, ['owner_tipo' => $ownerTipo, 'owner_id' => $ownerId])))->save()) {
                $salvos++;
            }
        }
```

por:

```php
        $tipo = null;
        if ($ownerTipo === Documento::OWNER_CARD_PAGAMENTO) {
            $tipo = (string) Yii::$app->request->post('tipo');
            if (!isset(Documento::TIPOS[$tipo])) { $tipo = Documento::TIPO_OUTRO; }
        }
        $files = UploadedFile::getInstancesByName('documentos');
        $salvos = 0;
        foreach ($files as $file) {
            $info = DocumentoHelper::salvarComoPdf($file, $this->dirDocs());
            $attrs = array_merge($info ?: [], ['owner_tipo' => $ownerTipo, 'owner_id' => $ownerId, 'tipo' => $tipo]);
            if ($info && (new Documento($attrs))->save()) {
                $salvos++;
            }
        }
```

- [ ] **Step 2: Adicionar o select de tipo no modal `up-pagto`**

Em `views/card/fechar.php`, no modal `up-pagto` (bloco que começa em ~linha 210), logo antes do `Html::fileInput('documentos[]', ...)` (linha ~220), insira:

```php
          <label class="modal-hint" style="display:block;margin-bottom:6px">Tipo do documento</label>
          <?= Html::dropDownList('tipo', Documento::TIPO_NF, Documento::TIPOS, ['class' => 'form-select', 'style' => 'margin-bottom:10px']) ?>
```

- [ ] **Step 3: Exibir o badge do tipo na lista de documentos de pagamento**

Em `views/card/fechar.php`, na lista de `docsPagto` (bloco `foreach ($docsPagto as $doc)`, ~linhas 148–157), após o `<span class="doc-name">...</span>` adicione:

```php
          <?php if ($doc->tipoLabel()): ?><span class="doc-tipo-badge"><?= Html::encode($doc->tipoLabel()) ?></span><?php endif; ?>
```

E adicione o estilo em `web/css/app.css` (junto aos estilos de `.card-docs`/`.doc-item`):

```css
.doc-tipo-badge{font-size:11px;font-weight:600;color:#4f6f00;background:#f3fbd6;border:1px solid #cfe6a8;border-radius:999px;padding:1px 8px;margin-left:6px}
```

- [ ] **Step 4: Verificar no navegador**

Suba o app, marque um card como Pago, abra "Anexar documentos", escolha o tipo "NF", anexe um arquivo. Confirme que o badge "NF" aparece ao lado do documento. Repita anexando um "Comprovante".

- [ ] **Step 5: Commit**

```bash
git add controllers/CardController.php views/card/fechar.php web/css/app.css
git commit -m "feat(card): escolhe e exibe o tipo do documento de pagamento"
```

### Task C3: Item 8 — Relatório "Cards sem NF"

**Files:**
- Create: `migrations/m260714_000003_seed_relatorio_cards_sem_nf.php`
- Modify: `tests/unit/models/RelatorioSeedTest.php:13-18`

- [ ] **Step 1: Criar a migration de seed do relatório**

Crie `migrations/m260714_000003_seed_relatorio_cards_sem_nf.php`:

```php
<?php

use yii\db\Migration;

class m260714_000003_seed_relatorio_cards_sem_nf extends Migration
{
    private const SLUG = 'cards-sem-nf';

    public function safeUp()
    {
        $now = time();
        $this->insert('{{%relatorio}}', [
            'slug' => self::SLUG, 'ordem' => 7,
            'nome' => 'Cards sem NF',
            'descricao' => 'Cards da quinzena com valor de nota e sem NF anexada.',
            'status' => 'ativo',
            'params' => json_encode([
                ['nome' => 'quinzena', 'label' => 'Quinzena', 'tipo' => 'quinzena'],
            ]),
            'colunas' => json_encode(['valor_nota' => 'monetario']),
            'sql' => <<<SQL
SELECT p.nome AS profissional,
       f.nome AS funcao,
       c.quinzena AS quinzena,
       c.total_nota AS valor_nota,
       c.status AS status
FROM card c
JOIN profissional p ON p.id = c.profissional_id
LEFT JOIN funcao f ON f.id = p.funcao_id
WHERE c.deleted_at IS NULL
  AND c.quinzena = :quinzena
  AND c.total_nota > 0
  AND NOT EXISTS (
      SELECT 1 FROM documento d
      WHERE d.owner_tipo = 'card_pagamento'
        AND d.owner_id = c.id
        AND d.tipo = 'nf'
        AND d.deleted_at IS NULL
  )
ORDER BY p.nome
SQL,
            'created_at' => $now, 'updated_at' => $now,
        ]);
    }

    public function safeDown()
    {
        $this->delete('{{%relatorio}}', ['slug' => self::SLUG]);
    }
}
```

- [ ] **Step 2: Atualizar o teste de seed para incluir o novo relatório**

Em `tests/unit/models/RelatorioSeedTest.php`, adicione `'cards-sem-nf'` à lista do `foreach` em `testSeisRelatoriosSemeados` (linhas 13–16):

```php
        foreach ([
            'custo-por-obra', 'produtividade-profissional', 'folha-quinzenal',
            'historico-profissional', 'efetivo-obra-periodo', 'anomalias',
            'cards-sem-nf',
        ] as $slug) {
```

- [ ] **Step 3: Aplicar a migration (dev e teste)**

Run (dev DB): `docker compose exec -T php sh -lc './yii migrate --interactive=0'`
Run (test DB): `docker compose exec -T php sh -lc 'php tests/bin/yii migrate --interactive=0'`
Expected: ambos aplicam `m260714_000003_seed_relatorio_cards_sem_nf`.

- [ ] **Step 4: Rodar o teste de seed**

Run: `docker compose exec php vendor/bin/codecept run Unit models/RelatorioSeedTest`
Expected: PASS — inclui o novo slug e `validarSelect` aceita o SQL (é um SELECT).

- [ ] **Step 5: Verificar no navegador**

Suba o app, vá em Relatórios → "Cards sem NF", selecione uma quinzena que tenha card com nota > 0 sem NF anexada. Confirme que ele aparece; anexe uma NF (Task C2) a esse card e confirme que ele some do relatório.

- [ ] **Step 6: Commit**

```bash
git add -A migrations/ tests/
git commit -m "feat(relatorio): aba de cards sem NF por quinzena"
```

### Task C4: Item 10 — Filtro "sem NF" na lista de cards

**Files:**
- Modify: `models/Card.php` (helper `temNf`)
- Modify: `models/CardSearch.php`
- Modify: `controllers/CardController.php:273-327` (`montarLinhas`)
- Modify: `views/card/_search.php`

- [ ] **Step 1: Adicionar `Card::temNf()`**

Em `models/Card.php`, adicione junto a `documentosPagamento()` (após linha ~278):

```php
    /** Há uma NF (documento de pagamento tipo NF, não deletado) anexada a este card? */
    public function temNf(): bool
    {
        return Documento::find()
            ->where([
                'owner_tipo' => Documento::OWNER_CARD_PAGAMENTO,
                'owner_id' => $this->id,
                'tipo' => Documento::TIPO_NF,
            ])->exists();
    }
```

- [ ] **Step 2: Adicionar o atributo `sem_nf` ao search**

Em `models/CardSearch.php`, adicione a propriedade após `public $status;`:

```php
    public $sem_nf;
```

E na `rules()`, adicione:

```php
            [['sem_nf'], 'boolean'],
```

E no `attributeLabels()`, adicione:

```php
            'sem_nf' => 'Somente sem NF',
```

- [ ] **Step 3: Aplicar o filtro em montarLinhas**

Em `controllers/CardController.php`, no fim de `montarLinhas` (antes do `usort`, ~linha 323), adicione:

```php
        if ($search->sem_nf) {
            $linhas = array_values(array_filter($linhas, fn($l) =>
                $l['card'] && (float) $l['card']->total_nota > 0 && !$l['card']->temNf()));
        }
```

- [ ] **Step 4: Adicionar o checkbox ao formulário de filtro**

Em `views/card/_search.php`, antes do `Html::submitButton('Filtrar', ...)` (linha 26), adicione:

```php
    <label class="filter-field filter-field--check">
        <span>NF</span>
        <span style="display:inline-flex;align-items:center;gap:6px">
            <?= Html::checkbox('sem_nf', (bool) $search->sem_nf, ['value' => 1]) ?>
            <span>Somente sem NF</span>
        </span>
    </label>
```

- [ ] **Step 5: Verificar no navegador**

Suba o app, na lista de cards marque "Somente sem NF" e filtre. Confirme que só aparecem cards com valor de nota > 0 e sem NF anexada; anexe uma NF a um deles e confirme que sai da lista ao filtrar de novo. Confirme que desmarcar volta a listar tudo.

- [ ] **Step 6: Rodar functional de card para garantir integridade**

Run: `docker compose exec php vendor/bin/codecept run Functional CardCest`
Expected: PASS

- [ ] **Step 7: Commit**

```bash
git add models/Card.php models/CardSearch.php controllers/CardController.php views/card/_search.php
git commit -m "feat(card): filtro 'somente sem NF' na lista de cards"
```

---

## Workstream D · Login com reCAPTCHA v3 (item 9)

### Task D1: Config e helper de verificação

**Files:**
- Modify: `config/secrets.example.php`
- Modify: `.env.example`
- Modify: `config/web.php:7` (exposição em params)
- Create: `components/RecaptchaHelper.php`
- Test: `tests/unit/components/RecaptchaHelperTest.php` (novo)

- [ ] **Step 1: Declarar as chaves no template de segredos**

Em `config/secrets.example.php`, dentro do array retornado (após `masterPasswordHash`), adicione:

```php
    // reCAPTCHA v3 (console: https://www.google.com/recaptcha/admin). Vazio = desativado.
    'recaptcha' => [
        'siteKey'   => getenv('RECAPTCHA_SITE_KEY') ?: '',
        'secretKey' => getenv('RECAPTCHA_SECRET_KEY') ?: '',
        'minScore'  => 0.5,
    ],
```

Em `.env.example`, adicione ao final:

```
# reCAPTCHA v3 (deixe vazio para desativar)
RECAPTCHA_SITE_KEY=
RECAPTCHA_SECRET_KEY=
```

> Nota operacional: preencha as chaves reais em `config/secrets.php` (gitignored) e no `.env` do ambiente. Sem elas, o reCAPTCHA fica desativado (login segue normal).

- [ ] **Step 2: Expor as chaves em params**

Em `config/web.php`, após a linha 7 (`$params['security.masterPasswordHash'] = ...`), adicione:

```php
$params['recaptcha.siteKey'] = $secrets['recaptcha']['siteKey'] ?? '';
$params['recaptcha.secretKey'] = $secrets['recaptcha']['secretKey'] ?? '';
$params['recaptcha.minScore'] = $secrets['recaptcha']['minScore'] ?? 0.5;
```

- [ ] **Step 3: Escrever o teste do núcleo de decisão**

Crie `tests/unit/components/RecaptchaHelperTest.php`:

```php
<?php

namespace app\tests\Unit\Components;

use app\components\RecaptchaHelper;

class RecaptchaHelperTest extends \Codeception\Test\Unit
{
    public function testFailOpenQuandoRespostaNula()
    {
        // resposta null = erro de rede → libera (fail-open)
        $this->assertTrue(RecaptchaHelper::decide(null, 0.5, 'login'));
    }

    public function testSuccessFalseReprova()
    {
        $this->assertFalse(RecaptchaHelper::decide(['success' => false], 0.5, 'login'));
    }

    public function testScoreBaixoReprova()
    {
        $this->assertFalse(RecaptchaHelper::decide(['success' => true, 'score' => 0.2, 'action' => 'login'], 0.5, 'login'));
    }

    public function testScoreAltoAprova()
    {
        $this->assertTrue(RecaptchaHelper::decide(['success' => true, 'score' => 0.9, 'action' => 'login'], 0.5, 'login'));
    }

    public function testAcaoDivergenteReprova()
    {
        $this->assertFalse(RecaptchaHelper::decide(['success' => true, 'score' => 0.9, 'action' => 'signup'], 0.5, 'login'));
    }
}
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit components/RecaptchaHelperTest:testFailOpenQuandoRespostaNula`
Expected: FAIL — classe `RecaptchaHelper` não existe.

- [ ] **Step 5: Criar o helper**

Crie `components/RecaptchaHelper.php`:

```php
<?php

declare(strict_types=1);

namespace app\components;

use Yii;

class RecaptchaHelper
{
    /** Verifica o token do reCAPTCHA v3. Desativado (true) se não configurado ou em teste.
     *  Fail-open: se o Google estiver inacessível, libera. */
    public static function verify(?string $token, string $action = 'login'): bool
    {
        if (YII_ENV_TEST) {
            return true;
        }
        $secret = (string) (Yii::$app->params['recaptcha.secretKey'] ?? '');
        if ($secret === '') {
            return true; // não configurado → desativado
        }
        if (!$token) {
            return false;
        }
        $min = (float) (Yii::$app->params['recaptcha.minScore'] ?? 0.5);
        return self::decide(self::siteverify($secret, $token), $min, $action);
    }

    /** Núcleo de decisão (puro, testável). $resp null = falha de rede → libera. */
    public static function decide(?array $resp, float $min, string $action): bool
    {
        if ($resp === null) {
            return true; // fail-open
        }
        if (empty($resp['success'])) {
            return false;
        }
        if (isset($resp['action']) && $resp['action'] !== $action) {
            return false;
        }
        return (float) ($resp['score'] ?? 0) >= $min;
    }

    /** POST ao siteverify. Retorna null em falha de rede/parse (para o fail-open). */
    private static function siteverify(string $secret, string $token): ?array
    {
        $ctx = stream_context_create(['http' => [
            'method' => 'POST',
            'header' => 'Content-Type: application/x-www-form-urlencoded',
            'content' => http_build_query(['secret' => $secret, 'response' => $token]),
            'timeout' => 5,
            'ignore_errors' => true,
        ]]);
        $raw = @file_get_contents('https://www.google.com/recaptcha/api/siteverify', false, $ctx);
        if ($raw === false) {
            return null;
        }
        $data = json_decode($raw, true);
        return is_array($data) ? $data : null;
    }
}
```

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

Run: `docker compose exec php vendor/bin/codecept run Unit components/RecaptchaHelperTest`
Expected: PASS (os 5 métodos)

- [ ] **Step 7: Commit**

```bash
git add config/secrets.example.php .env.example config/web.php components/RecaptchaHelper.php tests/unit/components/RecaptchaHelperTest.php
git commit -m "feat(login): helper de verificacao do reCAPTCHA v3 + config"
```

### Task D2: Ligar o reCAPTCHA no login e remover o CAPTCHA antigo

**Files:**
- Modify: `models/LoginForm.php`
- Modify: `controllers/SiteController.php:14,25,36-46`
- Modify: `views/site/login.php`
- Modify: `tests/functional/LoginCest.php:31-38`

- [ ] **Step 1: Trocar a regra no LoginForm**

Em `models/LoginForm.php`:

Remova a propriedade `public string $verifyCode = '';` e adicione:

```php
    public string $recaptchaToken = '';
```

Na `rules()`, remova `['verifyCode', 'captcha'],` e adicione:

```php
            ['recaptchaToken', 'validateRecaptcha'],
```

No `attributeLabels()`, remova a entrada `'verifyCode' => 'Código de verificação'`.

Adicione o método (após `validatePassword`):

```php
    public function validateRecaptcha($attribute, $params): void
    {
        if ($this->hasErrors()) {
            return;
        }
        if (!\app\components\RecaptchaHelper::verify($this->recaptchaToken, 'login')) {
            $this->addError($attribute, 'Falha na verificação de segurança. Tente novamente.');
        }
    }
```

- [ ] **Step 2: Remover o CaptchaAction do SiteController**

Em `controllers/SiteController.php`:

- Linha 14: remova `use yii\captcha\CaptchaAction;`
- Linha 25: troque `['actions' => ['login', 'error', 'captcha'], 'allow' => true],` por `['actions' => ['login', 'error'], 'allow' => true],`
- Em `actions()` (linhas 36–46): remova a entrada `'captcha' => [...]`, deixando apenas:

```php
    public function actions(): array
    {
        return [
            'error' => ['class' => ErrorAction::class],
        ];
    }
```

- [ ] **Step 3: Atualizar a view de login**

Em `views/site/login.php`, substitua o bloco do `verifyCode` (linhas 19–26) por um hidden do token + carregamento do reCAPTCHA quando houver siteKey e fora de teste. Troque:

```php
            <?php if (YII_ENV_TEST): ?>
                <?= $form->field($model, 'verifyCode')->hiddenInput()->label(false) ?>
            <?php else: ?>
                <?= $form->field($model, 'verifyCode')->widget(Captcha::class, [
                    'captchaAction' => 'site/captcha',
                    'template' => '<div class="captcha-row">{image}{input}</div>',
                ]) ?>
            <?php endif; ?>
```

por:

```php
            <?= $form->field($model, 'recaptchaToken')->hiddenInput(['id' => 'recaptcha-token'])->label(false) ?>
```

E remova o `use yii\captcha\Captcha;` do topo do arquivo. Ao final do arquivo (após `</div>`), adicione:

```php
<?php
$siteKey = (string) (Yii::$app->params['recaptcha.siteKey'] ?? '');
if ($siteKey && !YII_ENV_TEST):
    $this->registerJsFile("https://www.google.com/recaptcha/api.js?render={$siteKey}",
        ['position' => \yii\web\View::POS_HEAD]);
    $js = <<<JS
(function(){
  var form = document.getElementById('login-form');
  if (!form) return;
  form.addEventListener('submit', function(e){
    if (form.dataset.recaptchaDone) return;
    e.preventDefault();
    grecaptcha.ready(function(){
      grecaptcha.execute('{$siteKey}', {action: 'login'}).then(function(token){
        document.getElementById('recaptcha-token').value = token;
        form.dataset.recaptchaDone = '1';
        form.submit();
      });
    });
  });
})();
JS;
    $this->registerJs($js);
endif;
?>
```

- [ ] **Step 4: Atualizar o LoginCest (remover verifyCode)**

Em `tests/functional/LoginCest.php`, no método `loginInvalidoMostraErro` (linhas 31–38), remova a linha `'LoginForm[verifyCode]' => 'testme',` do `submitForm`, ficando:

```php
        $I->submitForm('#login-form', [
            'LoginForm[login]' => 'admin',
            'LoginForm[password]' => 'errado',
        ]);
```

- [ ] **Step 5: Rodar o LoginCest**

Run: `docker compose exec php vendor/bin/codecept run Functional LoginCest`
Expected: PASS — em teste o reCAPTCHA é desativado (`verify` retorna true em `YII_ENV_TEST`), então o fluxo de login/erro continua igual.

- [ ] **Step 6: Verificar no navegador (com chaves reais)**

Preencha `RECAPTCHA_SITE_KEY`/`RECAPTCHA_SECRET_KEY` em `.env`/`config/secrets.php`, reinicie o container, e faça login. Confirme que não há mais o CAPTCHA de imagem, que o login funciona, e (DevTools → Network) que há uma chamada ao `siteverify` no submit. Teste o fail-open desligando temporariamente a rede do container: o login deve continuar passando.

- [ ] **Step 7: Commit**

```bash
git add models/LoginForm.php controllers/SiteController.php views/site/login.php tests/functional/LoginCest.php
git commit -m "feat(login): substitui captcha nativo por reCAPTCHA v3"
```

---

## Fechamento

- [ ] **Rodar a suíte completa**

Run: `docker compose exec php vendor/bin/codecept run`
Expected: PASS (Unit + Functional). Corrija regressões antes de abrir o PR.

- [ ] **Revisão final**

Confira visualmente os 10 itens no app e revise o diff (`git log --oneline master..HEAD`) antes do PR na branch `feat/fechamento-ajustes-v1`.

---

## Rastreabilidade (spec → tasks)

| Item | Descrição | Task(s) |
|------|-----------|---------|
| 1 | Turno noturno (saída < entrada) | A1 |
| 2 | Retroativo exige saída | A2 |
| 3 | Líder aparece no efetivo | A3 (dropdown) |
| 4 | Dobra no mesmo dia com trava de horário | A3 |
| 5 | Anexo do desconto clicável | B1 |
| 6 | Aviso ao salvar bônus/desconto meio-preenchido | B2 |
| 7 | Tipo de documento (NF/Comprovante/Outro) | C1, C2 |
| 8 | Relatório "Cards sem NF" | C3 |
| 9 | reCAPTCHA v3 no login | D1, D2 |
| 10 | Filtro "sem NF" na lista de cards | C4 |
