# Ajustes pós-validação do fechamento (v1.0) — 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:** Implementar os 6 ajustes solicitados pelo cliente na área de card/fechamento do Citrus: valor da nota + flag por desconto, bônus mensal do líder no card, correção de diárias por obra, anexos de documentos, histórico com responsável, e filtros por período + escopo de líder + lista enriquecida.

**Architecture:** Cada fase é um incremento independente e shipável (mapeia a um PR). Models de negócio estendem `BaseActiveRecord` (soft-delete + auditoria + timestamps). Controllers usam `PermissaoBehavior` + `VerbFilter`. Migrations seguem `mYYMMDD_NNNNNN_*`. Conversão imagem→PDF reutiliza o mpdf já instalado. Escopo de líder é centralizado em `Obra::visiveisPara()` + `Profissional::ehLiderPuro()`.

**Tech Stack:** PHP 8.2, Yii2 (yii2-app-basic), MySQL 8, mpdf 8.3, GD, Bootstrap 5, Codeception. **Tudo roda dentro do container `php`** (o PHP do PATH é 7.4 e quebra).

**Convenções de teste:**
- Rodar um arquivo de teste: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
- Rodar a suíte: `docker compose exec php vendor/bin/codecept run Unit`
- Testes unitários ficam em `tests/unit/`, namespace `app\tests\Unit\...`, e usam fixtures de `tests/fixtures/`.
- Fixtures relevantes já existentes: profissional `id=1` (Admin, perfil `administrativo`, valor_diaria 0), `id=2` (Líder Um, perfis `profissional,lider`, valor_diaria 150); obra `id=100` (lider1_id=2), `id=101` (sem líder). Função `id=10`.

**Aplicar migrations depois de criar:** `docker compose exec php ./yii migrate`

---

## File Structure

**Fase 1 — Valor da nota + bônus de líder**
- Create: `migrations/m260624_000001_add_considera_nota_to_card_item.php`
- Create: `migrations/m260624_000002_add_total_nota_to_card.php`
- Modify: `models/CardItem.php` (regra `considera_nota`)
- Modify: `models/Card.php` (`recalcularTotais` calcula `total_nota`; bônus de líder em `abrirParaEdicao`; regra `total_nota`)
- Modify: `controllers/CardController.php` (`actionSalvar` persiste `considera_nota`)
- Modify: `views/card/fechar.php` (checkbox por desconto; Valor a receber / Valor da nota)
- Modify: `views/card/_pdf.php` (renomear "Total líquido" → "Valor a receber" + "Valor da nota")
- Modify: `web/js/card.js` (recálculo ao vivo do valor da nota)
- Test: `tests/unit/Models/CardTest.php`

**Fase 2 — Diárias sempre por obra (bug Ana Paula)**
- Modify: `models/Card.php` (`garantirDiariasPorObra()`, `avisoDivergenciaDiarias()`, helpers de contagem; chamada em `abrirParaEdicao`)
- Modify: `views/card/fechar.php` (banner de divergência)
- Test: `tests/unit/Models/CardTest.php`

**Fase 3 — Anexos de documentos**
- Create: `migrations/m260624_000003_create_documento_table.php`
- Create: `models/Documento.php`
- Create: `components/DocumentoHelper.php`
- Modify: `controllers/CardController.php` (salvar id-based; actions `upload-doc`/`excluir-doc`/`baixar-doc`; behaviors)
- Modify: `models/Card.php` (`documentosPagamento()`), `models/CardItem.php` (`getDocumento()`)
- Modify: `views/card/fechar.php` (item_id por linha; seção comprovantes; modal de pagamento)
- Modify: `views/card/_status.php` (botão "Marcar como pago" abre modal multipart)
- Modify: `web/js/card.js` (sync hidden da flag nota; modal)
- Test: `tests/unit/DocumentoHelperTest.php`

**Fase 4 — Histórico com responsável**
- Modify: `models/CardStatusHistorico.php` (`getUsuario()`)
- Modify: `views/card/_status.php` (exibir "por <nome>")

**Fase 5 — Filtros, escopo de líder, lista enriquecida**
- Create: `models/CardSearch.php`
- Modify: `models/Quinzena.php` (`entre()`)
- Modify: `models/Profissional.php` (`ehLiderPuro()`)
- Modify: `models/Obra.php` (`visiveisPara()`)
- Modify: `models/Card.php` (`obrasTrabalhadas()`)
- Modify: `controllers/CardController.php` (`actionIndex` reescrito; `actionExport`)
- Modify: `controllers/EfetivoController.php` (usar helpers centralizados)
- Modify: `models/ObraSearch.php` (escopo de líder)
- Modify: `controllers/ObraController.php` (`actionExport` com escopo)
- Modify: `views/relatorio/_filtros.php` (obras com escopo)
- Modify: `views/card/_search.php` (novo filtro)
- Modify: `views/card/index.php` (quinzena, obras, ícones de download)
- Test: `tests/unit/Models/QuinzenaTest.php`, `tests/unit/Models/ObraVisibilidadeTest.php`

---

# FASE 1 — Valor da nota + bônus de líder

### Task 1.1: Migration `considera_nota` em `card_item`

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

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

```php
<?php

use yii\db\Migration;

class m260624_000001_add_considera_nota_to_card_item extends Migration
{
    public function safeUp()
    {
        $this->addColumn('{{%card_item}}', 'considera_nota',
            $this->tinyInteger(1)->notNull()->defaultValue(0)->after('subtotal'));
    }

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

- [ ] **Step 2: Aplicar a migration**

Run: `docker compose exec php ./yii migrate`
Expected: aplica `m260624_000001...` sem erro.

- [ ] **Step 3: Commit**

```bash
git add migrations/m260624_000001_add_considera_nota_to_card_item.php
git commit -m "feat(card): coluna considera_nota em card_item"
```

---

### Task 1.2: Migration `total_nota` em `card`

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

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

```php
<?php

use yii\db\Migration;

class m260624_000002_add_total_nota_to_card extends Migration
{
    public function safeUp()
    {
        $this->addColumn('{{%card}}', 'total_nota',
            $this->decimal(10, 2)->notNull()->defaultValue(0)->after('total_liquido'));
    }

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

- [ ] **Step 2: Aplicar a migration**

Run: `docker compose exec php ./yii migrate`
Expected: aplica `m260624_000002...` sem erro.

- [ ] **Step 3: Commit**

```bash
git add migrations/m260624_000002_add_total_nota_to_card.php
git commit -m "feat(card): coluna total_nota em card"
```

---

### Task 1.3: `CardItem` valida `considera_nota`

**Files:**
- Modify: `models/CardItem.php`

- [ ] **Step 1: Adicionar regra**

Em `models/CardItem.php`, dentro de `rules()`, adicionar após a linha `[['quantidade', 'valor_unitario', 'subtotal'], 'number'],`:

```php
            [['considera_nota'], 'integer'],
            [['considera_nota'], 'default', 'value' => 0],
```

- [ ] **Step 2: Commit**

```bash
git add models/CardItem.php
git commit -m "feat(card): regra considera_nota em CardItem"
```

---

### Task 1.4: `Card::recalcularTotais()` calcula `total_nota`

**Files:**
- Modify: `models/Card.php:24-34` (rules) e `models/Card.php:61-74` (recalcularTotais)
- Test: `tests/unit/Models/CardTest.php`

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

Adicionar ao `tests/unit/Models/CardTest.php` (a classe já tem o helper `efetivo()` e `_fixtures()`):

```php
    public function testValorNotaSomaApenasDescontosMarcados()
    {
        $this->efetivo(100, ['2026-12-15', '2026-12-16'], 2); // 2 × 150 = 300
        $card = Card::abrirParaEdicao(2, '2026-12-1');
        // Adiantamento marcado p/ nota (entra na nota), Falta não marcada (fica fora)
        (new CardItem(['card_id' => $card->id, 'tipo' => 'desconto', 'descricao' => 'Adiantamento',
            'quantidade' => 1, 'valor_unitario' => 200, 'subtotal' => 200, 'considera_nota' => 1]))->save();
        (new CardItem(['card_id' => $card->id, 'tipo' => 'desconto', 'descricao' => 'Falta',
            'quantidade' => 1, 'valor_unitario' => 50, 'subtotal' => 50, 'considera_nota' => 0]))->save();
        $card->recalcularTotais();
        // Valor a receber = 300 - 250 = 50
        $this->assertEquals(50, (float) $card->total_liquido);
        // Valor da nota = diárias + bônus + descontos marcados = 300 + 0 + 200 = 500
        $this->assertEquals(500, (float) $card->total_nota);
    }
```

- [ ] **Step 2: Rodar o teste e verificar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: FAIL em `testValorNotaSomaApenasDescontosMarcados` (total_nota = 0, esperado 500).

- [ ] **Step 3: Implementar**

Em `models/Card.php`, substituir o método `recalcularTotais()` inteiro por:

```php
    public function recalcularTotais(): void
    {
        $d = $b = $desc = 0.0;
        $notaDesc = 0.0;
        foreach ($this->getItens()->all() as $i) {
            if ($i->tipo === CardItem::TIPO_DIARIA) { $d += (float) $i->subtotal; }
            elseif ($i->tipo === CardItem::TIPO_BONUS) { $b += (float) $i->subtotal; }
            else {
                $desc += (float) $i->subtotal;
                if ((int) $i->considera_nota === 1) { $notaDesc += (float) $i->subtotal; }
            }
        }
        $this->total_diarias = $d;
        $this->total_bonus = $b;
        $this->total_desconto = $desc;
        $this->total_liquido = $d + $b - $desc;
        $this->total_nota = $d + $b + $notaDesc;
        $this->save(false, ['total_diarias', 'total_bonus', 'total_desconto', 'total_liquido', 'total_nota', 'updated_at']);
    }
```

Ainda em `models/Card.php`, em `rules()`, alterar a linha:

```php
            [['total_diarias', 'total_bonus', 'total_desconto', 'total_liquido'], 'number'],
```

para:

```php
            [['total_diarias', 'total_bonus', 'total_desconto', 'total_liquido', 'total_nota'], 'number'],
```

- [ ] **Step 4: Rodar o teste e verificar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: PASS (todos os testes da classe).

- [ ] **Step 5: Commit**

```bash
git add models/Card.php tests/unit/Models/CardTest.php
git commit -m "feat(card): calcula total_nota (valor da nota)"
```

---

### Task 1.5: Bônus de líder inserido na criação do card

**Files:**
- Modify: `models/Card.php:116-125` (`abrirParaEdicao`)
- Test: `tests/unit/Models/CardTest.php`

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

Adicionar ao `tests/unit/Models/CardTest.php`. O profissional `id=2` é líder com `valor_diaria=150`; precisamos definir `valor_bonus_mensal`. Faça-o no teste:

```php
    public function testCardDeLiderRecebeBonusMensalNaCriacao()
    {
        $lider = \app\models\Profissional::findOne(2);
        $lider->valor_bonus_mensal = 500; // mensal → metade no card
        $lider->save(false, ['valor_bonus_mensal']);

        $this->efetivo(100, ['2026-12-22'], 2); // 1 × 150
        $card = Card::abrirParaEdicao(2, '2026-12-1');

        $bonus = $card->itensPorTipo(CardItem::TIPO_BONUS);
        $this->assertCount(1, $bonus);
        $this->assertEquals(250, (float) $bonus[0]->subtotal); // 500 / 2
        $this->assertStringContainsString('líder', mb_strtolower($bonus[0]->descricao));
        $this->assertEquals(250, (float) $card->total_bonus);
    }

    public function testCardDeNaoLiderNaoRecebeBonusMensal()
    {
        $this->efetivo(100, ['2026-12-23'], 1); // prof 1 (admin, não-líder)
        $card = Card::abrirParaEdicao(1, '2026-12-1');
        $this->assertCount(0, $card->itensPorTipo(CardItem::TIPO_BONUS));
    }
```

- [ ] **Step 2: Rodar o teste e verificar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: FAIL em `testCardDeLiderRecebeBonusMensalNaCriacao` (0 bônus, esperado 1).

- [ ] **Step 3: Implementar**

Em `models/Card.php`, substituir o método `abrirParaEdicao()` inteiro por:

```php
    public static function abrirParaEdicao(int $profissionalId, string $quinzena): self
    {
        $card = self::find()->where(['profissional_id' => $profissionalId, 'quinzena' => $quinzena])->one();
        if (!$card) {
            $card = new self(['profissional_id' => $profissionalId, 'quinzena' => $quinzena, 'status' => self::STATUS_RASCUNHO]);
            $card->save();
            $card->recalcularDiarias();
            $card->inserirBonusLider();
        }
        return $card;
    }

    /** Insere (uma única vez, na criação) o bônus mensal do líder: metade do valor mensal. */
    private function inserirBonusLider(): void
    {
        $prof = $this->profissional;
        if (!$prof || !$prof->temPerfil('lider')) { return; }
        $mensal = (float) $prof->valor_bonus_mensal;
        if ($mensal <= 0) { return; }
        $metade = round($mensal / 2, 2);
        (new CardItem([
            'card_id' => $this->id, 'tipo' => CardItem::TIPO_BONUS, 'obra_id' => null,
            'descricao' => 'Bônus mensal de líder (½ de R$ ' . number_format($mensal, 2, ',', '.') . ')',
            'quantidade' => 1, 'valor_unitario' => $metade, 'subtotal' => $metade,
        ]))->save();
        $this->recalcularTotais();
    }
```

- [ ] **Step 4: Rodar o teste e verificar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add models/Card.php tests/unit/Models/CardTest.php
git commit -m "feat(card): bonus mensal do lider inserido na criacao do card"
```

---

### Task 1.6: `actionSalvar` persiste `considera_nota` por desconto

**Files:**
- Modify: `controllers/CardController.php:60-92` (`actionSalvar`)

- [ ] **Step 1: Implementar**

Em `controllers/CardController.php`, dentro de `actionSalvar`, localizar o bloco:

```php
        \app\models\CardItem::deleteAll(['card_id' => $card->id, 'tipo' => ['bonus', 'desconto']]);
        foreach (['bonus', 'desconto'] as $tipo) {
            $descs = (array) Yii::$app->request->post($tipo . '_descricao', []);
            $vals = (array) Yii::$app->request->post($tipo . '_valor', []);
            foreach ($descs as $i => $rawDesc) {
                $desc = trim((string) $rawDesc);
                $valor = self::parseMoney($vals[$i] ?? '0');
                if ($desc === '' || $valor <= 0) { continue; }
                (new \app\models\CardItem([
                    'card_id' => $card->id, 'tipo' => $tipo, 'descricao' => $desc,
                    'quantidade' => 1, 'valor_unitario' => $valor, 'subtotal' => $valor,
                ]))->save();
            }
        }
```

e substituí-lo por:

```php
        \app\models\CardItem::deleteAll(['card_id' => $card->id, 'tipo' => ['bonus', 'desconto']]);
        $notas = (array) Yii::$app->request->post('desconto_nota', []);
        foreach (['bonus', 'desconto'] as $tipo) {
            $descs = (array) Yii::$app->request->post($tipo . '_descricao', []);
            $vals = (array) Yii::$app->request->post($tipo . '_valor', []);
            foreach ($descs as $i => $rawDesc) {
                $desc = trim((string) $rawDesc);
                $valor = self::parseMoney($vals[$i] ?? '0');
                if ($desc === '' || $valor <= 0) { continue; }
                (new \app\models\CardItem([
                    'card_id' => $card->id, 'tipo' => $tipo, 'descricao' => $desc,
                    'quantidade' => 1, 'valor_unitario' => $valor, 'subtotal' => $valor,
                    'considera_nota' => ($tipo === 'desconto') ? (int) ($notas[$i] ?? 0) : 0,
                ]))->save();
            }
        }
```

- [ ] **Step 2: Commit**

```bash
git add controllers/CardController.php
git commit -m "feat(card): salvar persiste considera_nota dos descontos"
```

---

### Task 1.7: Tela do card — checkbox por desconto + Valor a receber / Valor da nota

**Files:**
- Modify: `views/card/fechar.php:62-75`

- [ ] **Step 1: Substituir o bloco de bônus/descontos e o bloco de totais**

Em `views/card/fechar.php`, localizar o bloco que vai da linha do `foreach (['bonus' => 'Bônus', ...])` até a linha do `card-total-breakdown` (linhas 62–75) e substituí-lo por:

```php
  <?php foreach (['bonus' => 'Bônus', 'desconto' => 'Descontos'] as $tipo => $titulo): ?>
    <h3 class="efetivo-lista-titulo"><?= $titulo ?> <?php if ($rascunho): ?><a href="#" class="card-add-item" data-tipo="<?= $tipo ?>">+ adicionar</a><?php endif; ?></h3>
    <div class="card-itens" data-tipo="<?= $tipo ?>">
      <?php foreach ($card->itensPorTipo($tipo) as $it): ?>
        <div class="card-item-row">
          <?= Html::input('text', "{$tipo}_descricao[]", $it->descricao, ['class' => 'form-control', 'placeholder' => 'Descrição', 'readonly' => !$rascunho]) ?>
          <?= Html::input('text', "{$tipo}_valor[]", (float) $it->subtotal, ['class' => 'form-control js-money', 'data' => ['mask-money' => 1], 'inputmode' => 'numeric', 'style' => 'width:130px', 'readonly' => !$rascunho]) ?>
          <?php if ($tipo === 'desconto'): ?>
            <label class="card-nota-check" title="Marque para somar este valor ao Valor da nota (ex.: adiantamento)">
              <?= Html::hiddenInput("desconto_nota[]", (int) $it->considera_nota, ['class' => 'js-nota-hidden']) ?>
              <input type="checkbox" class="js-considera-nota" <?= $it->considera_nota ? 'checked' : '' ?> <?= $rascunho ? '' : 'disabled' ?>>
              <span>Considerar no valor da nota</span>
            </label>
          <?php endif; ?>
        </div>
      <?php endforeach; ?>
    </div>
  <?php endforeach; ?>

  <div class="card-total card-total--nota"><strong>Valor da nota</strong><strong class="card-nota-v"><?= $money($card->total_nota) ?></strong></div>
  <div class="card-total card-total--receber"><span>Valor a receber</span><span class="card-total-v"><?= $money($card->total_liquido) ?></span></div>
  <div class="muted card-total-breakdown" style="text-align:right;font-size:11px"><?= $money($card->total_diarias) ?> + <?= $money($card->total_bonus) ?> − <?= $money($card->total_desconto) ?></div>
```

> Nota: o `hidden` `desconto_nota[]` é a fonte de verdade enviada ao servidor (sempre submete 0/1, alinhado por posição com `desconto_descricao[]`/`desconto_valor[]`). O checkbox é só visual e atualiza o hidden via JS (Task 1.8). Mantém a ordem `hidden → checkbox` em cada linha.

- [ ] **Step 2: Verificação manual**

Abrir um card em rascunho com um desconto. Confirmar: aparece o checkbox "Considerar no valor da nota"; "Valor da nota" aparece com destaque acima de "Valor a receber".

- [ ] **Step 3: Commit**

```bash
git add views/card/fechar.php
git commit -m "feat(card): checkbox de nota por desconto e valor da nota na tela"
```

---

### Task 1.8: JS — recálculo ao vivo do Valor da nota

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

- [ ] **Step 1: Substituir a função `recompute` e os handlers**

Em `web/js/card.js`, substituir a função `recompute()` inteira (linhas 26–51) por:

```js
  function recompute() {
    var diarias = 0;
    form.querySelectorAll('.js-diaria-row').forEach(function (row) {
      var qtdEl = row.querySelector('.js-diaria-qtd');
      var sub;
      if (qtdEl) {
        sub = qtdVal(qtdEl) * (parseFloat(row.getAttribute('data-valor-unit')) || 0);
        var cell = row.querySelector('.js-diaria-subtotal');
        if (cell) cell.textContent = fmt(sub);
      } else {
        sub = parseFloat(row.getAttribute('data-subtotal')) || 0;
      }
      diarias += sub;
    });

    var bonus = 0;
    form.querySelectorAll('.card-itens[data-tipo="bonus"] .js-money').forEach(function (el) { bonus += moneyVal(el); });

    var desconto = 0, descontoNota = 0;
    form.querySelectorAll('.card-itens[data-tipo="desconto"] .card-item-row').forEach(function (row) {
      var el = row.querySelector('.js-money');
      if (!el) return;
      var v = moneyVal(el);
      desconto += v;
      var chk = row.querySelector('.js-considera-nota');
      var hid = row.querySelector('.js-nota-hidden');
      if (chk && hid) { hid.value = chk.checked ? '1' : '0'; }
      if (chk && chk.checked) { descontoNota += v; }
    });

    var elDiarias = form.querySelector('.js-total-diarias');
    if (elDiarias) elDiarias.textContent = fmt(diarias);
    var elTotal = document.querySelector('.card-total-v');
    if (elTotal) elTotal.textContent = fmt(diarias + bonus - desconto);
    var elNota = document.querySelector('.card-nota-v');
    if (elNota) elNota.textContent = fmt(diarias + bonus + descontoNota);
    var elBreak = document.querySelector('.card-total-breakdown');
    if (elBreak) elBreak.textContent = fmt(diarias) + ' + ' + fmt(bonus) + ' − ' + fmt(desconto);
  }
```

Ainda em `web/js/card.js`, logo após `form.addEventListener('input', recompute);` (linha 73), adicionar:

```js
  form.addEventListener('change', recompute);
```

E no handler de `.card-add-item` (criação de linha dinâmica), substituir o `row.innerHTML = ...` por uma versão que, para descontos, inclui o checkbox + hidden:

```js
      var html = '<input type="text" class="form-control" name="' + tipo + '_descricao[]" placeholder="Descrição">' +
                 '<input type="text" class="form-control js-money" style="width:130px" inputmode="numeric" name="' + tipo + '_valor[]" placeholder="R$ 0,00">';
      if (tipo === 'desconto') {
        html += '<label class="card-nota-check" title="Marque para somar ao Valor da nota">' +
                '<input type="hidden" class="js-nota-hidden" name="desconto_nota[]" value="0">' +
                '<input type="checkbox" class="js-considera-nota">' +
                '<span>Considerar no valor da nota</span></label>';
      }
      row.innerHTML = html;
```

- [ ] **Step 2: Verificação manual**

Em um card rascunho: marcar/desmarcar o checkbox de um desconto e confirmar que "Valor da nota" recalcula na hora; adicionar uma linha de desconto via "+ adicionar" e confirmar que vem com checkbox.

- [ ] **Step 3: Commit**

```bash
git add web/js/card.js
git commit -m "feat(card): recalculo ao vivo do valor da nota no JS"
```

---

### Task 1.9: PDF — renomear "Total líquido" e adicionar "Valor da nota"

**Files:**
- Modify: `views/card/_pdf.php:41-46` (estilos) e `views/card/_pdf.php:137-142` (resumo)

- [ ] **Step 1: Ajustar o resumo do PDF**

Em `views/card/_pdf.php`, localizar o bloco `<table class="resumo" align="right">` (linhas 137–142) e substituí-lo por:

```php
      <table class="resumo" align="right">
        <tr><td class="lbl">Diárias</td><td class="r"><?= $money($card->total_diarias) ?></td></tr>
        <tr><td class="lbl">Bônus</td><td class="r">+ <?= $money($card->total_bonus) ?></td></tr>
        <tr><td class="lbl">Descontos</td><td class="r neg">− <?= $money($card->total_desconto) ?></td></tr>
        <tr class="receber-row"><td>Valor a receber</td><td class="r"><?= $money($card->total_liquido) ?></td></tr>
        <tr class="total-row"><td>Valor da nota</td><td class="r"><?= $money($card->total_nota) ?></td></tr>
      </table>
```

- [ ] **Step 2: Ajustar os estilos**

Em `views/card/_pdf.php`, na seção `<style>`, localizar a linha `.total-row td { ... }` (linha 46) e substituí-la por (mantém o destaque verde no Valor da nota e adiciona um estilo discreto para Valor a receber):

```css
  .receber-row td { color: #555; font-size: 11px; border-bottom: 0; padding: 6px 10px; }
  .total-row td { background: #C0F024; color: #0C0C0C; font-weight: bold; font-size: 14px; border-bottom: 0; padding: 10px; }
```

- [ ] **Step 3: Verificação manual**

Gerar o PDF de um card (botão "Gerar PDF"). Confirmar: "Valor a receber" aparece discreto e "Valor da nota" aparece com mais destaque (faixa verde) logo abaixo.

- [ ] **Step 4: Commit**

```bash
git add views/card/_pdf.php
git commit -m "feat(card): PDF mostra Valor a receber e Valor da nota"
```

---

# FASE 2 — Diárias sempre por obra (bug Ana Paula, id 26)

> **Causa-raiz confirmada:** as diárias exibidas vêm de `CardItem` armazenados, gerados só na criação/recálculo e apenas em rascunho (`Card::recalcularDiarias`, `Card.php:78`). A contagem "Nº diárias" da lista é uma query ao vivo do efetivo. Quando o efetivo de uma 2ª obra é registrado depois (ou após o fechamento), o card não o incorpora. Solução: sincronizar obras faltantes ao abrir (sem apagar itens/qtd manuais) e avisar divergência em cards fechado/pago.

### Task 2.1: `Card::garantirDiariasPorObra()` adiciona obras faltantes

**Files:**
- Modify: `models/Card.php` (novo método + chamada em `abrirParaEdicao`)
- Test: `tests/unit/Models/CardTest.php`

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

Adicionar ao `tests/unit/Models/CardTest.php`:

```php
    public function testGarantirDiariasAdicionaObraFaltante()
    {
        // Card criado quando só existia efetivo da obra 100
        $this->efetivo(100, ['2026-11-03'], 2);
        $card = Card::abrirParaEdicao(2, '2026-11-1');
        $this->assertCount(1, $card->itensPorTipo(CardItem::TIPO_DIARIA));

        // Depois, efetivo numa SEGUNDA obra (101) é registrado
        $this->efetivo(101, ['2026-11-04'], 2);

        // Reabrir o card sincroniza a obra faltante (sem apagar a existente)
        $card2 = Card::abrirParaEdicao(2, '2026-11-1');
        $diarias = $card2->itensPorTipo(CardItem::TIPO_DIARIA);
        $this->assertCount(2, $diarias);
        $obraIds = array_map(fn($i) => (int) $i->obra_id, $diarias);
        sort($obraIds);
        $this->assertEquals([100, 101], $obraIds);
    }
```

- [ ] **Step 2: Rodar o teste e verificar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: FAIL em `testGarantirDiariasAdicionaObraFaltante` (1 diária, esperado 2).

- [ ] **Step 3: Implementar**

Em `models/Card.php`, alterar `abrirParaEdicao()` para sincronizar quando o card já existe. Substituir o corpo do método por:

```php
    public static function abrirParaEdicao(int $profissionalId, string $quinzena): self
    {
        $card = self::find()->where(['profissional_id' => $profissionalId, 'quinzena' => $quinzena])->one();
        if (!$card) {
            $card = new self(['profissional_id' => $profissionalId, 'quinzena' => $quinzena, 'status' => self::STATUS_RASCUNHO]);
            $card->save();
            $card->recalcularDiarias();
            $card->inserirBonusLider();
        } else {
            $card->garantirDiariasPorObra();
        }
        return $card;
    }
```

Ainda em `models/Card.php`, adicionar o novo método (logo após `recalcularDiarias()`):

```php
    /** Adiciona itens de diária para obras com efetivo que ainda não estão no card.
     *  NÃO apaga nem sobrescreve itens existentes (preserva ajustes manuais de quantidade).
     *  Só roda em rascunho e para profissionais diaristas. */
    public function garantirDiariasPorObra(): void
    {
        if ($this->status !== self::STATUS_RASCUNHO) { return; }
        $prof = $this->profissional;
        if (!$prof || ($prof->funcao && $prof->funcao->tipo_remuneracao === 'quinzena')) { return; }

        [$ini, $fim] = Quinzena::intervalo($this->quinzena);
        $rows = Efetivo::find()->joinWith('efetivoDia')
            ->where(['efetivo.profissional_id' => $prof->id])
            ->andWhere(['between', 'efetivo_dia.data', $ini, $fim])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->all();
        $porObra = [];
        foreach ($rows as $r) {
            $oid = (int) $r->efetivoDia->obra_id;
            $porObra[$oid] = ($porObra[$oid] ?? 0) + 1;
        }
        $existentes = array_map('intval',
            CardItem::find()->where(['card_id' => $this->id, 'tipo' => CardItem::TIPO_DIARIA])->select('obra_id')->column());

        $adicionou = false;
        foreach ($porObra as $obraId => $count) {
            if (in_array($obraId, $existentes, true)) { continue; }
            $obra = Obra::findOne($obraId);
            $valor = (float) $prof->valor_diaria;
            (new CardItem([
                'card_id' => $this->id, 'tipo' => CardItem::TIPO_DIARIA, 'obra_id' => $obraId ?: null,
                'descricao' => $obra->nome ?? '', 'quantidade' => $count,
                'valor_unitario' => $valor, 'subtotal' => $count * $valor,
            ]))->save();
            $adicionou = true;
        }
        if ($adicionou) { $this->recalcularTotais(); }
    }
```

- [ ] **Step 4: Rodar o teste e verificar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add models/Card.php tests/unit/Models/CardTest.php
git commit -m "fix(card): sincroniza obras faltantes nas diarias ao abrir o card"
```

---

### Task 2.2: Aviso de divergência em cards fechado/pago

**Files:**
- Modify: `models/Card.php` (helpers de contagem + aviso)
- Modify: `views/card/fechar.php` (banner)
- Test: `tests/unit/Models/CardTest.php`

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

Adicionar ao `tests/unit/Models/CardTest.php`:

```php
    public function testAvisoDivergenciaQuandoCardFechadoTemMenosDiarias()
    {
        $this->efetivo(100, ['2026-10-02'], 2);
        $card = Card::abrirParaEdicao(2, '2026-10-1');
        $card->mudarStatus(Card::STATUS_FECHADO, null); // fecha com 1 diária

        // efetivo novo aparece depois do fechamento (não entra no card fechado)
        $this->efetivo(101, ['2026-10-03'], 2);

        $this->assertNotNull($card->avisoDivergenciaDiarias());
    }

    public function testSemAvisoQuandoCardBateComEfetivo()
    {
        $this->efetivo(100, ['2026-10-10'], 2);
        $card = Card::abrirParaEdicao(2, '2026-10-1');
        $card->mudarStatus(Card::STATUS_FECHADO, null);
        $this->assertNull($card->avisoDivergenciaDiarias());
    }
```

- [ ] **Step 2: Rodar o teste e verificar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: FAIL (método `avisoDivergenciaDiarias` não existe).

- [ ] **Step 3: Implementar**

Em `models/Card.php`, adicionar (após `garantirDiariasPorObra()`):

```php
    public function contagemEfetivoQuinzena(): int
    {
        [$ini, $fim] = Quinzena::intervalo($this->quinzena);
        return (int) Efetivo::find()->joinWith('efetivoDia')
            ->where(['efetivo.profissional_id' => $this->profissional_id])
            ->andWhere(['between', 'efetivo_dia.data', $ini, $fim])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->count();
    }

    public function somaDiariasCard(): float
    {
        $s = 0.0;
        foreach ($this->itensPorTipo(CardItem::TIPO_DIARIA) as $it) { $s += (float) $it->quantidade; }
        return $s;
    }

    /** Mensagem de divergência (efetivo ao vivo × diárias do card) para cards não-rascunho diaristas; null se ok. */
    public function avisoDivergenciaDiarias(): ?string
    {
        if ($this->status === self::STATUS_RASCUNHO) { return null; }
        $prof = $this->profissional;
        if ($prof && $prof->funcao && $prof->funcao->tipo_remuneracao === 'quinzena') { return null; }
        $live = $this->contagemEfetivoQuinzena();
        $card = (int) round($this->somaDiariasCard());
        if ($live === $card) { return null; }
        return "Divergência: o efetivo registra {$live} diária(s) nesta quinzena, mas o card tem {$card}. "
            . 'Reabra o card e use "Recalcular" para sincronizar.';
    }
```

- [ ] **Step 4: Rodar o teste e verificar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/CardTest.php`
Expected: PASS.

- [ ] **Step 5: Exibir o banner na tela**

Em `views/card/fechar.php`, logo após a `<div class="card-head"> ... </div>` (depois da linha 28, antes do `ActiveForm::begin`), adicionar:

```php
  <?php if ($aviso = $card->avisoDivergenciaDiarias()): ?>
    <div class="alert alert-warning"><?= Html::encode($aviso) ?></div>
  <?php endif; ?>
```

- [ ] **Step 6: Commit**

```bash
git add models/Card.php views/card/fechar.php tests/unit/Models/CardTest.php
git commit -m "feat(card): aviso de divergencia de diarias em cards fechado/pago"
```

---

# FASE 3 — Anexos de documentos

### Task 3.1: Migration `documento`

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

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

```php
<?php

use yii\db\Migration;

class m260624_000003_create_documento_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%documento}}', [
            'id' => $this->primaryKey(),
            'owner_tipo' => $this->string(20)->notNull(),   // 'card_item' | 'card_pagamento'
            'owner_id' => $this->integer()->notNull(),
            'nome_original' => $this->string(255)->null(),
            'arquivo' => $this->string(255)->notNull(),     // nome do PDF em uploads/documentos
            'mime' => $this->string(100)->null(),
            'created_at' => $this->integer()->notNull(),
            'updated_at' => $this->integer()->notNull(),
            'deleted_at' => $this->integer()->null(),
        ]);
        $this->createIndex('idx-documento-owner', '{{%documento}}', ['owner_tipo', 'owner_id']);
    }

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

- [ ] **Step 2: Aplicar a migration**

Run: `docker compose exec php ./yii migrate`
Expected: aplica `m260624_000003...` sem erro.

- [ ] **Step 3: Commit**

```bash
git add migrations/m260624_000003_create_documento_table.php
git commit -m "feat(documento): tabela documento (anexos polimorficos)"
```

---

### Task 3.2: Model `Documento`

**Files:**
- Create: `models/Documento.php`

- [ ] **Step 1: Criar o model**

```php
<?php

namespace app\models;

class Documento extends BaseActiveRecord
{
    public const OWNER_CARD_ITEM = 'card_item';
    public const OWNER_CARD_PAGAMENTO = 'card_pagamento';

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

    public function rules()
    {
        return [
            [['owner_tipo', 'owner_id', 'arquivo'], 'required'],
            [['owner_id'], 'integer'],
            [['owner_tipo'], 'in', 'range' => [self::OWNER_CARD_ITEM, self::OWNER_CARD_PAGAMENTO]],
            [['owner_tipo', 'mime'], 'string', 'max' => 100],
            [['nome_original', 'arquivo'], 'string', 'max' => 255],
        ];
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add models/Documento.php
git commit -m "feat(documento): model Documento"
```

---

### Task 3.3: `DocumentoHelper` (imagem → PDF)

**Files:**
- Create: `components/DocumentoHelper.php`
- Test: `tests/unit/DocumentoHelperTest.php`

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

Create `tests/unit/DocumentoHelperTest.php`:

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

use app\components\DocumentoHelper;

class DocumentoHelperTest extends \Codeception\Test\Unit
{
    private function pngTemporario(): string
    {
        $src = sys_get_temp_dir() . '/citrus-test-' . uniqid() . '.png';
        $img = imagecreatetruecolor(60, 40);
        imagefill($img, 0, 0, imagecolorallocate($img, 200, 240, 36));
        imagepng($img, $src);
        imagedestroy($img);
        return $src;
    }

    public function testImagemViraPdf()
    {
        $src = $this->pngTemporario();
        $dest = sys_get_temp_dir() . '/citrus-out-' . uniqid() . '.pdf';
        DocumentoHelper::imagemParaPdf($src, $dest);
        $this->assertFileExists($dest);
        $this->assertStringStartsWith('%PDF-', file_get_contents($dest));
        @unlink($src);
        @unlink($dest);
    }
}
```

- [ ] **Step 2: Rodar o teste e verificar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit DocumentoHelperTest.php`
Expected: FAIL (classe `DocumentoHelper` não existe).

- [ ] **Step 3: Implementar**

Create `components/DocumentoHelper.php`:

```php
<?php

namespace app\components;

use Yii;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use yii\web\UploadedFile;

class DocumentoHelper
{
    public const EXT_OK = ['jpg', 'jpeg', 'png', 'pdf'];

    /** Salva o upload como PDF em $dir. Retorna ['arquivo','mime','nome_original'] ou null se extensão inválida. */
    public static function salvarComoPdf(UploadedFile $file, string $dir): ?array
    {
        $ext = strtolower((string) $file->extension);
        if (!in_array($ext, self::EXT_OK, true)) { return null; }
        if (!is_dir($dir)) { mkdir($dir, 0775, true); }

        $nomePdf = 'doc-' . uniqid('', true) . '.pdf';
        $destino = $dir . '/' . $nomePdf;
        if ($ext === 'pdf') {
            $file->saveAs($destino);
        } else {
            self::imagemParaPdf($file->tempName, $destino);
        }
        return ['arquivo' => $nomePdf, 'mime' => 'application/pdf', 'nome_original' => $file->name];
    }

    /** Converte uma imagem (jpg/png) em PDF A4 (orientação conforme proporção). */
    public static function imagemParaPdf(string $src, string $destPdf): void
    {
        $tempDir = Yii::getAlias('@runtime/mpdf');
        if (!is_dir($tempDir)) { mkdir($tempDir, 0775, true); }
        [$w, $h] = getimagesize($src);
        $orientacao = ($w > $h) ? 'L' : 'P';
        $mpdf = new Mpdf(['tempDir' => $tempDir, 'format' => 'A4', 'orientation' => $orientacao]);
        $mpdf->WriteHTML('<img src="' . $src . '" style="width:100%;">');
        $mpdf->Output($destPdf, Destination::FILE);
    }
}
```

- [ ] **Step 4: Rodar o teste e verificar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit DocumentoHelperTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add components/DocumentoHelper.php tests/unit/DocumentoHelperTest.php
git commit -m "feat(documento): DocumentoHelper converte imagem em PDF"
```

---

### Task 3.4: Relações de documentos em `Card` e `CardItem`

**Files:**
- Modify: `models/Card.php`, `models/CardItem.php`

- [ ] **Step 1: Adicionar métodos**

Em `models/Card.php`, adicionar (após `efetivosDaObra()`):

```php
    /** Documentos de pagamento anexados a este card. */
    public function documentosPagamento(): array
    {
        return Documento::find()
            ->where(['owner_tipo' => Documento::OWNER_CARD_PAGAMENTO, 'owner_id' => $this->id])
            ->orderBy('id')->all();
    }
```

Em `models/CardItem.php`, adicionar (após `getObra()`):

```php
    /** Documento (único) anexado a este item de bônus/desconto. */
    public function documento(): ?Documento
    {
        return Documento::find()
            ->where(['owner_tipo' => Documento::OWNER_CARD_ITEM, 'owner_id' => $this->id])
            ->one();
    }
```

- [ ] **Step 2: Commit**

```bash
git add models/Card.php models/CardItem.php
git commit -m "feat(documento): relacoes de documentos em Card e CardItem"
```

---

### Task 3.5: `actionSalvar` id-based (preserva identidade dos itens para anexos)

> Necessário para que documentos por bônus/desconto sobrevivam a edições do card (hoje `salvar` apaga e recria todos os itens, o que invalidaria os anexos).

**Files:**
- Modify: `controllers/CardController.php` (`actionSalvar`)
- Modify: `views/card/fechar.php` (hidden `item_id[]` por linha)

- [ ] **Step 1: Render do `item_id` por linha**

Em `views/card/fechar.php`, no `foreach ($card->itensPorTipo($tipo) as $it)` (bloco editado na Task 1.7), adicionar como **primeiro** elemento dentro da `<div class="card-item-row">`:

```php
          <?= Html::hiddenInput("{$tipo}_item_id[]", $it->id, ['class' => 'js-item-id']) ?>
```

E no handler `.card-add-item` do `web/js/card.js` (Task 1.8), adicionar ao início da string `html` (para que novas linhas enviem id vazio):

```js
      var html = '<input type="hidden" class="js-item-id" name="' + tipo + '_item_id[]" value="">';
```
(concatene o restante do `html` já existente com `+=` em seguida.)

- [ ] **Step 2: Reescrever `actionSalvar` para upsert por id**

Em `controllers/CardController.php`, substituir o bloco de itens de `actionSalvar` (o trecho da Task 1.6, do `deleteAll(...)` até o fim do `foreach (['bonus','desconto'])`) por:

```php
        $notas = (array) Yii::$app->request->post('desconto_nota', []);
        $mantidos = [];
        foreach (['bonus', 'desconto'] as $tipo) {
            $ids = (array) Yii::$app->request->post($tipo . '_item_id', []);
            $descs = (array) Yii::$app->request->post($tipo . '_descricao', []);
            $vals = (array) Yii::$app->request->post($tipo . '_valor', []);
            foreach ($descs as $i => $rawDesc) {
                $desc = trim((string) $rawDesc);
                $valor = self::parseMoney($vals[$i] ?? '0');
                if ($desc === '' || $valor <= 0) { continue; }
                $itemId = (int) ($ids[$i] ?? 0);
                $item = $itemId
                    ? \app\models\CardItem::findOne(['id' => $itemId, 'card_id' => $card->id, 'tipo' => $tipo])
                    : null;
                if (!$item) { $item = new \app\models\CardItem(['card_id' => $card->id, 'tipo' => $tipo]); }
                $item->descricao = $desc;
                $item->quantidade = 1;
                $item->valor_unitario = $valor;
                $item->subtotal = $valor;
                $item->considera_nota = ($tipo === 'desconto') ? (int) ($notas[$i] ?? 0) : 0;
                $item->save();
                $mantidos[] = (int) $item->id;
            }
        }
        // remove (soft-delete) os bônus/descontos que sumiram do formulário, junto com seus documentos
        $removidos = \app\models\CardItem::find()
            ->where(['card_id' => $card->id, 'tipo' => ['bonus', 'desconto']])
            ->andWhere(['not in', 'id', $mantidos ?: [0]])->all();
        foreach ($removidos as $r) {
            foreach (\app\models\Documento::find()->where(['owner_tipo' => \app\models\Documento::OWNER_CARD_ITEM, 'owner_id' => $r->id])->all() as $d) {
                $d->delete();
            }
            $r->delete();
        }
```

- [ ] **Step 3: Verificação manual**

Abrir um card rascunho, adicionar 2 descontos, salvar; reabrir, editar 1 e salvar; confirmar que os ids dos itens não-removidos se mantêm (ex.: inspecionar `card_item` no banco — `docker compose exec mysql ...` — ou confirmar que anexos da Task 3.6 persistem após salvar).

- [ ] **Step 4: Commit**

```bash
git add controllers/CardController.php views/card/fechar.php web/js/card.js
git commit -m "refactor(card): salvar faz upsert de itens por id (preserva anexos)"
```

---

### Task 3.6: Actions de documento no `CardController`

**Files:**
- Modify: `controllers/CardController.php` (behaviors + 3 actions)

- [ ] **Step 1: Atualizar behaviors**

Em `controllers/CardController.php`, no `behaviors()`, no `acaoMap`, adicionar as entradas:

```php
                    'upload-doc' => 'update', 'excluir-doc' => 'update', 'baixar-doc' => 'view',
```

e no `verbs` → `actions`, adicionar:

```php
                    'upload-doc' => ['post'], 'excluir-doc' => ['post'],
```

- [ ] **Step 2: Adicionar imports**

No topo de `controllers/CardController.php`, adicionar aos `use`:

```php
use app\models\Documento;
use app\models\CardItem;
use app\components\DocumentoHelper;
use yii\web\UploadedFile;
```

- [ ] **Step 3: Implementar as actions**

Adicionar ao `CardController` (antes de `findCard()`):

```php
    private function dirDocs(): string
    {
        $dir = Yii::getAlias('@app/uploads/documentos');
        if (!is_dir($dir)) { mkdir($dir, 0775, true); }
        return $dir;
    }

    public function actionUploadDoc(int $id)
    {
        $card = $this->findCard($id);
        $ownerTipo = (string) Yii::$app->request->post('owner_tipo');

        if ($ownerTipo === Documento::OWNER_CARD_PAGAMENTO) {
            $ownerId = $card->id;
        } elseif ($ownerTipo === Documento::OWNER_CARD_ITEM) {
            $ownerId = (int) Yii::$app->request->post('owner_id');
            $item = CardItem::findOne(['id' => $ownerId, 'card_id' => $card->id]);
            if (!$item) { throw new \yii\web\NotFoundHttpException('Item não encontrado.'); }
            // 1 documento por item: remove o anterior
            foreach (Documento::find()->where(['owner_tipo' => $ownerTipo, 'owner_id' => $ownerId])->all() as $d) {
                $d->delete();
            }
        } else {
            throw new \yii\web\BadRequestHttpException('Tipo de anexo inválido.');
        }

        $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++;
            }
        }
        Yii::$app->session->setFlash($salvos ? 'success' : 'error',
            $salvos ? 'Documento(s) anexado(s).' : 'Nenhum arquivo válido (use JPG, PNG ou PDF).');
        return $this->redirect(['fechar', 'profissional_id' => $card->profissional_id, 'quinzena' => $card->quinzena]);
    }

    public function actionExcluirDoc(int $id)
    {
        $card = $this->findCard($id);
        $doc = Documento::findOne((int) Yii::$app->request->post('doc_id'));
        if ($doc && $this->docPertenceAoCard($doc, $card)) {
            $doc->delete();
            Yii::$app->session->setFlash('success', 'Documento removido.');
        }
        return $this->redirect(['fechar', 'profissional_id' => $card->profissional_id, 'quinzena' => $card->quinzena]);
    }

    public function actionBaixarDoc(int $id)
    {
        $doc = Documento::findOne($id);
        if (!$doc) { throw new \yii\web\NotFoundHttpException('Documento não encontrado.'); }
        $path = $this->dirDocs() . '/' . $doc->arquivo;
        if (!is_file($path)) { throw new \yii\web\NotFoundHttpException('Arquivo ausente.'); }
        $nome = pathinfo((string) ($doc->nome_original ?: $doc->arquivo), PATHINFO_FILENAME) . '.pdf';
        return Yii::$app->response->sendFile($path, $nome);
    }

    private function docPertenceAoCard(Documento $doc, Card $card): bool
    {
        if ($doc->owner_tipo === Documento::OWNER_CARD_PAGAMENTO) {
            return (int) $doc->owner_id === (int) $card->id;
        }
        return (bool) CardItem::findOne(['id' => $doc->owner_id, 'card_id' => $card->id]);
    }
```

- [ ] **Step 4: Commit**

```bash
git add controllers/CardController.php
git commit -m "feat(documento): actions upload/excluir/baixar no CardController"
```

---

### Task 3.7: UI — comprovantes por bônus/desconto e modal de pagamento

**Files:**
- Modify: `views/card/fechar.php`
- Modify: `views/card/_status.php`

- [ ] **Step 1: Seção de comprovantes (fora do card-form)**

Em `views/card/fechar.php`, **depois** do `ActiveForm::end()` (linha 85) e **antes** do `render('@app/views/card/_status', ...)`, adicionar uma seção que lista bônus/descontos salvos com anexo (separada do card-form para permitir multipart e não aninhar forms):

```php
  <h3 class="efetivo-lista-titulo">Comprovantes de bônus/descontos</h3>
  <div class="card-list">
    <?php foreach ($card->getItens()->andWhere(['tipo' => ['bonus', 'desconto']])->orderBy('id')->all() as $it): ?>
      <?php $doc = $it->documento(); ?>
      <div class="entity-card" style="display:flex;justify-content:space-between;align-items:center;gap:10px">
        <div><strong><?= Html::encode(ucfirst($it->tipo)) ?></strong> · <?= Html::encode($it->descricao) ?> · <?= $money($it->subtotal) ?></div>
        <div>
          <?php if ($doc): ?>
            <?= Html::a('⬇ Comprovante', ['card/baixar-doc', 'id' => $doc->id], ['class' => 'btn-soft', 'target' => '_blank']) ?>
            <?= Html::beginForm(['card/excluir-doc', 'id' => $card->id], 'post', ['style' => 'display:inline']) ?>
              <?= Html::hiddenInput('doc_id', $doc->id) ?>
              <?= Html::submitButton('Remover', ['class' => 'btn-soft']) ?>
            <?= Html::endForm() ?>
          <?php else: ?>
            <?= Html::beginForm(['card/upload-doc', 'id' => $card->id], 'post', ['enctype' => 'multipart/form-data', 'style' => 'display:inline']) ?>
              <?= Html::hiddenInput('owner_tipo', \app\models\Documento::OWNER_CARD_ITEM) ?>
              <?= Html::hiddenInput('owner_id', $it->id) ?>
              <?= Html::fileInput('documentos[]', null, ['accept' => '.jpg,.jpeg,.png,.pdf']) ?>
              <?= Html::submitButton('Anexar', ['class' => 'btn-soft']) ?>
            <?= Html::endForm() ?>
          <?php endif; ?>
        </div>
      </div>
    <?php endforeach; ?>
    <?php if (!$card->getItens()->andWhere(['tipo' => ['bonus', 'desconto']])->count()): ?>
      <p class="em-breve">Nenhum bônus ou desconto neste card.</p>
    <?php endif; ?>
  </div>

  <h3 class="efetivo-lista-titulo">Documentos de pagamento</h3>
  <div class="card-list">
    <?php foreach ($card->documentosPagamento() as $doc): ?>
      <div class="entity-card" style="display:flex;justify-content:space-between;align-items:center;gap:10px">
        <div>📄 <?= Html::encode($doc->nome_original ?: $doc->arquivo) ?></div>
        <div>
          <?= Html::a('⬇ Baixar', ['card/baixar-doc', 'id' => $doc->id], ['class' => 'btn-soft', 'target' => '_blank']) ?>
          <?= Html::beginForm(['card/excluir-doc', 'id' => $card->id], 'post', ['style' => 'display:inline']) ?>
            <?= Html::hiddenInput('doc_id', $doc->id) ?>
            <?= Html::submitButton('Remover', ['class' => 'btn-soft']) ?>
          <?= Html::endForm() ?>
        </div>
      </div>
    <?php endforeach; ?>
    <?php if ($card->status === \app\models\Card::STATUS_PAGO): ?>
      <?= Html::beginForm(['card/upload-doc', 'id' => $card->id], 'post', ['enctype' => 'multipart/form-data']) ?>
        <?= Html::hiddenInput('owner_tipo', \app\models\Documento::OWNER_CARD_PAGAMENTO) ?>
        <?= Html::fileInput('documentos[]', null, ['accept' => '.jpg,.jpeg,.png,.pdf', 'multiple' => true]) ?>
        <?= Html::submitButton('Anexar documentos', ['class' => 'btn-soft']) ?>
      <?= Html::endForm() ?>
    <?php elseif (!$card->documentosPagamento()): ?>
      <p class="em-breve">Os documentos de pagamento podem ser anexados ao marcar como pago.</p>
    <?php endif; ?>
  </div>
```

- [ ] **Step 2: Modal de pagamento no `_status.php`**

Em `views/card/_status.php`, substituir a linha do botão "Marcar como pago" (dentro do `elseif ($card->status === Card::STATUS_FECHADO)`):

```php
        <?= $btn(Card::STATUS_PAGO, 'Marcar como pago', false) ?>
```

por um botão que abre um modal Bootstrap com upload de múltiplos documentos:

```php
        <button type="button" class="btn-lime" data-bs-toggle="modal" data-bs-target="#modal-pagar">Marcar como pago</button>
        <div class="modal fade" id="modal-pagar" tabindex="-1" aria-hidden="true">
          <div class="modal-dialog">
            <?= Html::beginForm(['card/mudar-status', 'id' => $card->id], 'post', ['enctype' => 'multipart/form-data', 'class' => 'modal-content']) ?>
              <div class="modal-header"><h5 class="modal-title">Confirmar pagamento</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button></div>
              <div class="modal-body">
                <?= Html::hiddenInput('status', Card::STATUS_PAGO) ?>
                <p>Anexe os documentos do pagamento (nota fiscal, comprovantes). Opcional — pode anexar depois.</p>
                <?= Html::fileInput('documentos[]', null, ['accept' => '.jpg,.jpeg,.png,.pdf', 'multiple' => true, 'class' => 'form-control']) ?>
              </div>
              <div class="modal-footer">
                <button type="button" class="btn-soft" data-bs-dismiss="modal">Cancelar</button>
                <?= Html::submitButton('Confirmar pagamento', ['class' => 'btn-lime']) ?>
              </div>
            <?= Html::endForm() ?>
          </div>
        </div>
```

- [ ] **Step 3: `actionMudarStatus` salva os documentos quando vira pago**

Em `controllers/CardController.php`, no `actionMudarStatus`, após o `if ($card->mudarStatus($novo, $motivo)) {` e **dentro** do bloco de sucesso, adicionar (antes do `setFlash`):

```php
            if ($novo === Card::STATUS_PAGO) {
                foreach (UploadedFile::getInstancesByName('documentos') as $file) {
                    $info = DocumentoHelper::salvarComoPdf($file, $this->dirDocs());
                    if ($info) {
                        (new Documento(array_merge($info, [
                            'owner_tipo' => Documento::OWNER_CARD_PAGAMENTO, 'owner_id' => $card->id,
                        ])))->save();
                    }
                }
            }
```

- [ ] **Step 4: Garantir o JS do Bootstrap (modal)**

O modal usa atributos `data-bs-*`, que exigem o JS do Bootstrap 5. Em `views/card/fechar.php`, junto ao `registerJsFile` já existente (linha 15), garantir o registro do plugin:

```php
\yii\bootstrap5\BootstrapPluginAsset::register($this);
```

Se ao testar o modal não abrir, esta é a causa. (Telas que já usam `ActiveForm` do bootstrap5 normalmente trazem o CSS, mas não necessariamente o JS do plugin.)

- [ ] **Step 5: Verificação manual**

1. Card fechado → "Marcar como pago" abre modal; anexar uma imagem JPG; confirmar → card vira pago e o JPG aparece como PDF em "Documentos de pagamento" com baixar/remover.
2. Card rascunho com um desconto salvo → anexar comprovante (imagem) na seção "Comprovantes"; confirmar download como PDF e remoção/reanexo.

- [ ] **Step 6: Commit**

```bash
git add views/card/fechar.php views/card/_status.php controllers/CardController.php
git commit -m "feat(documento): UI de anexos por item e modal de pagamento"
```

---

# FASE 4 — Histórico com responsável

### Task 4.1: Relação `getUsuario()` e exibição

**Files:**
- Modify: `models/CardStatusHistorico.php`
- Modify: `views/card/_status.php:32-36`

- [ ] **Step 1: Adicionar a relação**

Em `models/CardStatusHistorico.php`, adicionar (após `rules()`):

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

E no topo do arquivo, garantir o import (logo após `namespace app\models;`):

```php
use app\models\Profissional;
```
(Se já estiver no mesmo namespace `app\models`, o import é dispensável — `Profissional::class` resolve direto. Não duplicar.)

- [ ] **Step 2: Exibir o responsável**

Em `views/card/_status.php`, substituir o bloco do `foreach ($card->historico as $h)` (linhas 32–37) por:

```php
    <?php foreach ($card->historico as $h): ?>
        <div class="entity-card" style="display:block">
            <div><strong><?= Html::encode($h->de_status ? "{$h->de_status} → {$h->para_status}" : $h->para_status) ?></strong></div>
            <div class="meta">
                <?= Yii::$app->formatter->asDatetime($h->created_at) ?>
                <?= $h->usuario ? ' · por ' . Html::encode($h->usuario->nome) : '' ?>
                <?= $h->motivo ? ' · ' . Html::encode($h->motivo) : '' ?>
            </div>
        </div>
    <?php endforeach; ?>
```

- [ ] **Step 3: Verificação manual**

Abrir um card com histórico (ex.: fechado por um usuário) e confirmar que aparece "· por <nome>" na linha do evento.

- [ ] **Step 4: Commit**

```bash
git add models/CardStatusHistorico.php views/card/_status.php
git commit -m "feat(card): historico de status mostra o responsavel"
```

---

# FASE 5 — Filtros por período, escopo de líder e lista enriquecida

### Task 5.1: `Profissional::ehLiderPuro()` e `Obra::visiveisPara()`

**Files:**
- Modify: `models/Profissional.php`, `models/Obra.php`
- Test: `tests/unit/Models/ObraVisibilidadeTest.php`

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

Create `tests/unit/Models/ObraVisibilidadeTest.php`:

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

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

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

    public function testLiderPuroVeApenasSuasObras()
    {
        $lider = Profissional::findOne(2); // perfis: profissional,lider → líder puro
        $this->assertTrue($lider->ehLiderPuro());
        $ids = Obra::visiveisPara($lider)->select('id')->column();
        $this->assertEquals([100], array_map('intval', $ids)); // só a obra onde é lider1
    }

    public function testAdministrativoVeTodasAsObras()
    {
        $admin = Profissional::findOne(1); // administrativo
        $this->assertFalse($admin->ehLiderPuro());
        $ids = Obra::visiveisPara($admin)->select('id')->column();
        sort($ids);
        $this->assertEquals([100, 101], array_map('intval', $ids));
    }
}
```

- [ ] **Step 2: Rodar o teste e verificar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/ObraVisibilidadeTest.php`
Expected: FAIL (`ehLiderPuro`/`visiveisPara` não existem).

- [ ] **Step 3: Implementar**

Em `models/Profissional.php`, adicionar (após `temPerfil()`):

```php
    /** Líder "puro": tem perfil líder mas não gerente nem administrativo. */
    public function ehLiderPuro(): bool
    {
        return $this->temPerfil('lider') && !$this->temPerfil('gerente') && !$this->temPerfil('administrativo');
    }
```

Em `models/Obra.php`, adicionar (após `getLider2()`):

```php
    /** Query de obras visíveis para o usuário: líder-puro vê só onde é lider1/lider2; demais veem todas. */
    public static function visiveisPara(?Profissional $u): \yii\db\ActiveQuery
    {
        $q = static::find();
        if ($u && $u->ehLiderPuro()) {
            $q->andWhere(['or', ['lider1_id' => $u->id], ['lider2_id' => $u->id]]);
        }
        return $q;
    }
```

- [ ] **Step 4: Rodar o teste e verificar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/ObraVisibilidadeTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add models/Profissional.php models/Obra.php tests/unit/Models/ObraVisibilidadeTest.php
git commit -m "feat(obra): escopo de visibilidade por lider (visiveisPara/ehLiderPuro)"
```

---

### Task 5.2: Aplicar o escopo de líder nas telas existentes

**Files:**
- Modify: `controllers/EfetivoController.php` (`ehLiderPuro`, `obrasDisponiveis`)
- Modify: `models/ObraSearch.php`
- Modify: `controllers/ObraController.php` (`actionExport`)
- Modify: `views/relatorio/_filtros.php`

- [ ] **Step 1: `EfetivoController` usa os helpers centralizados**

Em `controllers/EfetivoController.php`, substituir o método `ehLiderPuro()` por:

```php
    /** O líder "puro" (sem gerente/admin) só registra hoje; gerente/admin escolhem a data. */
    private function ehLiderPuro(): bool
    {
        return Yii::$app->user->identity->ehLiderPuro();
    }
```

E substituir o método `obrasDisponiveis()` por:

```php
    /** Obras que o usuário pode lançar. */
    private function obrasDisponiveis(): array
    {
        return Obra::visiveisPara(Yii::$app->user->identity)
            ->andWhere(['status' => 'ativo'])->orderBy('nome')->all();
    }
```

- [ ] **Step 2: `ObraSearch` aplica o escopo**

Em `models/ObraSearch.php`, na função `search()`, substituir a linha:

```php
        $query = Obra::find()->with('lider1', 'lider2');
```

por:

```php
        $query = Obra::visiveisPara(\Yii::$app->user->identity ?? null)->with('lider1', 'lider2');
```

- [ ] **Step 3: `ObraController::actionExport` aplica o escopo**

Em `controllers/ObraController.php`, no `actionExport()`, substituir:

```php
        foreach (Obra::find()->with('lider1', 'lider2')->orderBy('nome')->all() as $o) {
```

por:

```php
        foreach (Obra::visiveisPara(Yii::$app->user->identity)->with('lider1', 'lider2')->orderBy('nome')->all() as $o) {
```

- [ ] **Step 4: Filtro de obra dos relatórios aplica o escopo**

Em `views/relatorio/_filtros.php`, substituir a linha 13:

```php
$obras = Obra::find()->where(['status' => 'ativo'])->orderBy('nome')->select(['nome', 'id'])->indexBy('id')->column();
```

por:

```php
$obras = Obra::visiveisPara(Yii::$app->user->identity)->andWhere(['status' => 'ativo'])->orderBy('nome')->select(['nome', 'id'])->indexBy('id')->column();
```

- [ ] **Step 5: Rodar a suíte (regressão)**

Run: `docker compose exec php vendor/bin/codecept run Unit`
Expected: PASS (nada quebrou).

- [ ] **Step 6: Commit**

```bash
git add controllers/EfetivoController.php models/ObraSearch.php controllers/ObraController.php views/relatorio/_filtros.php
git commit -m "feat(obra): aplica escopo de lider em efetivo, obras e relatorios"
```

---

### Task 5.3: `Quinzena::entre()` (quinzenas que se sobrepõem a um período)

**Files:**
- Modify: `models/Quinzena.php`
- Test: `tests/unit/Models/QuinzenaTest.php`

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

Adicionar ao `tests/unit/Models/QuinzenaTest.php` um método de teste:

```php
    public function testEntreRetornaQuinzenasQueSobrepoemOPeriodo()
    {
        $qs = \app\models\Quinzena::entre('2026-06-10', '2026-07-05');
        $this->assertEquals(['2026-6-1', '2026-6-2', '2026-7-1'], $qs);
    }

    public function testEntreUmUnicoDiaCaiNaQuinzenaCerta()
    {
        $this->assertEquals(['2026-6-2'], \app\models\Quinzena::entre('2026-06-20', '2026-06-20'));
    }
```

- [ ] **Step 2: Rodar o teste e verificar que falha**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/QuinzenaTest.php`
Expected: FAIL (método `entre` não existe).

- [ ] **Step 3: Implementar**

Em `models/Quinzena.php`, adicionar (após `disponiveis()`):

```php
    /** Lista de chaves de quinzena ("AAAA-M-Q") cujo intervalo se sobrepõe a [$ini, $fim] (Y-m-d). */
    public static function entre(string $ini, string $fim): array
    {
        $out = [];
        $cursor = strtotime(date('Y-m-01', strtotime($ini)));
        $end = strtotime($fim);
        $start = strtotime($ini);
        while ($cursor <= $end) {
            $y = (int) date('Y', $cursor);
            $m = (int) date('n', $cursor);
            foreach ([1, 2] as $q) {
                $key = "$y-$m-$q";
                [$qi, $qf] = self::intervalo($key);
                if (strtotime($qi) <= $end && strtotime($qf) >= $start) {
                    $out[] = $key;
                }
            }
            $cursor = strtotime('+1 month', $cursor);
        }
        return $out;
    }
```

- [ ] **Step 4: Rodar o teste e verificar que passa**

Run: `docker compose exec php vendor/bin/codecept run Unit Models/QuinzenaTest.php`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add models/Quinzena.php tests/unit/Models/QuinzenaTest.php
git commit -m "feat(quinzena): entre() retorna quinzenas que sobrepoem um periodo"
```

---

### Task 5.4: `CardSearch` e `Card::obrasTrabalhadas()`

**Files:**
- Create: `models/CardSearch.php`
- Modify: `models/Card.php`

- [ ] **Step 1: Criar `CardSearch`**

Create `models/CardSearch.php`:

```php
<?php

namespace app\models;

use yii\base\Model;

class CardSearch extends Model
{
    public $data_inicial;
    public $data_final;
    public $obra_id;
    public $profissional_id;
    public $status;

    public function rules()
    {
        return [
            [['data_inicial', 'data_final'], 'required', 'message' => 'Informe o período.'],
            [['data_inicial', 'data_final'], 'date', 'format' => 'php:Y-m-d'],
            [['obra_id', 'profissional_id'], 'integer'],
            [['status'], 'in', 'range' => ['rascunho', 'fechado', 'pago', 'pendente']],
            ['data_final', 'compare', 'compareAttribute' => 'data_inicial', 'operator' => '>=',
                'message' => 'A data final deve ser maior ou igual à inicial.'],
        ];
    }

    public function attributeLabels()
    {
        return [
            'data_inicial' => 'Data inicial', 'data_final' => 'Data final',
            'obra_id' => 'Obra', 'profissional_id' => 'Profissional', 'status' => 'Status',
        ];
    }
}
```

- [ ] **Step 2: `Card::obrasTrabalhadas()`**

Em `models/Card.php`, adicionar (após `documentosPagamento()`):

```php
    /** Nomes distintos das obras com diárias neste card. */
    public function obrasTrabalhadas(): array
    {
        $nomes = [];
        foreach ($this->itensPorTipo(CardItem::TIPO_DIARIA) as $it) {
            if ($it->obra_id && $it->obra) { $nomes[$it->obra_id] = $it->obra->nome; }
        }
        return array_values($nomes);
    }
```

- [ ] **Step 3: Commit**

```bash
git add models/CardSearch.php models/Card.php
git commit -m "feat(card): CardSearch e obrasTrabalhadas"
```

---

### Task 5.5: `CardController::actionIndex` reescrito (período + filtros + escopo)

**Files:**
- Modify: `controllers/CardController.php:143-187`

- [ ] **Step 1: Adicionar imports**

No topo de `controllers/CardController.php`, garantir nos `use` (o `CardItem` pode já ter sido adicionado na Fase 3; não duplicar):

```php
use app\models\CardSearch;
use app\models\Obra;
use app\models\CardItem;
```

- [ ] **Step 2: Substituir `actionIndex`**

Substituir o método `actionIndex()` inteiro por:

```php
    public function actionIndex()
    {
        $search = new CardSearch();
        $search->load(Yii::$app->request->queryParams, '');
        $linhas = [];

        if ($search->validate()) {
            $rank = [Card::STATUS_RASCUNHO => 1, Card::STATUS_FECHADO => 2, Card::STATUS_PAGO => 3];
            foreach (Quinzena::entre($search->data_inicial, $search->data_final) as $q) {
                [$ini, $fim] = Quinzena::intervalo($q);

                $efQ = Efetivo::find()->joinWith('efetivoDia')
                    ->where(['between', 'efetivo_dia.data', $ini, $fim])
                    ->andWhere(['efetivo_dia.deleted_at' => null]);
                if ($search->obra_id) { $efQ->andWhere(['efetivo_dia.obra_id' => $search->obra_id]); }
                $efRows = $efQ->select(['pid' => 'efetivo.profissional_id'])->asArray()->all();
                $diariasPorProf = array_count_values(array_column($efRows, 'pid'));

                $quinzenistas = [];
                if (!$search->obra_id) {
                    $funcoesQuinzena = Funcao::find()->select('id')->where(['tipo_remuneracao' => 'quinzena'])->column();
                    $quinzenistas = $funcoesQuinzena
                        ? Profissional::find()->where(['status' => 'ativo', 'funcao_id' => $funcoesQuinzena])->select('id')->column()
                        : [];
                }

                $cards = Card::find()->where(['quinzena' => $q])->indexBy('profissional_id')->all();
                if ($search->obra_id && $cards) {
                    $cardIds = array_map(fn($c) => $c->id, $cards);
                    $comObra = CardItem::find()->select('card_id')
                        ->where(['tipo' => 'diaria', 'obra_id' => $search->obra_id, 'card_id' => $cardIds])->column();
                    $cards = array_filter($cards, fn($c) => in_array($c->id, $comObra));
                }

                $ids = array_values(array_unique(array_merge(array_keys($diariasPorProf), $quinzenistas, array_keys($cards))));
                if ($search->profissional_id) {
                    $ids = array_values(array_intersect($ids, [(int) $search->profissional_id]));
                }
                if (!$ids) { continue; }

                $profs = Profissional::find()->where(['id' => $ids])->with('funcao')->indexBy('id')->all();
                foreach ($ids as $pid) {
                    if (!isset($profs[$pid])) { continue; }
                    $card = $cards[$pid] ?? null;
                    $status = $card ? $card->status : 'pendente';
                    if ($search->status && $search->status !== $status) { continue; }
                    $linhas[] = [
                        'profissional' => $profs[$pid], 'quinzena' => $q,
                        'diarias' => $diariasPorProf[$pid] ?? 0, 'card' => $card,
                        'status' => $status, 'rank' => $card ? ($rank[$status] ?? 9) : 0,
                    ];
                }
            }
            usort($linhas, fn($a, $b) =>
                [$a['quinzena'], $a['rank'], (string) $a['profissional']->nome]
                <=> [$b['quinzena'], $b['rank'], (string) $b['profissional']->nome]);
        }

        $u = Yii::$app->user->identity;
        $obras = Obra::visiveisPara($u)->andWhere(['status' => 'ativo'])
            ->orderBy('nome')->select(['nome', 'id'])->indexBy('id')->column();
        $profsOpt = Profissional::find()->where(['status' => 'ativo'])
            ->orderBy('nome')->select(['nome', 'id'])->indexBy('id')->column();

        return $this->render('index', compact('search', 'linhas', 'obras', 'profsOpt'));
    }
```

- [ ] **Step 3: Verificação manual (após Task 5.6)**

Abrir `card/index` sem período → mensagem pedindo o período; com período (ex.: 01/06–30/06) → lista os cards/profissionais das quinzenas sobrepostas; aplicar filtros de obra/profissional/status.

- [ ] **Step 4: Commit**

```bash
git add controllers/CardController.php
git commit -m "feat(card): index filtra por periodo, obra, profissional e status"
```

---

### Task 5.6: Novo filtro `_search.php` e lista enriquecida `index.php`

**Files:**
- Modify: `views/card/_search.php`
- Modify: `views/card/index.php`

- [ ] **Step 1: Reescrever `_search.php`**

Substituir todo o conteúdo de `views/card/_search.php` por:

```php
<?php
/** @var app\models\CardSearch $search */
/** @var array $obras */
/** @var array $profsOpt */
use yii\bootstrap5\Html;

$rotuloStatus = ['rascunho' => 'Rascunho', 'fechado' => 'Fechado', 'pago' => 'Pago', 'pendente' => 'Pendente'];
?>
<form method="get" action="<?= \yii\helpers\Url::to(['card/index']) ?>" class="filter-form filter-form--inline">
    <label class="filter-field"><span>Data inicial *</span>
        <?= Html::input('date', 'data_inicial', $search->data_inicial, ['class' => 'form-control', 'required' => true]) ?>
    </label>
    <label class="filter-field"><span>Data final *</span>
        <?= Html::input('date', 'data_final', $search->data_final, ['class' => 'form-control', 'required' => true]) ?>
    </label>
    <label class="filter-field"><span>Obra</span>
        <?= Html::dropDownList('obra_id', $search->obra_id, $obras, ['class' => 'form-select', 'prompt' => 'Todas']) ?>
    </label>
    <label class="filter-field"><span>Profissional</span>
        <?= Html::dropDownList('profissional_id', $search->profissional_id, $profsOpt, ['class' => 'form-select', 'prompt' => 'Todos']) ?>
    </label>
    <label class="filter-field"><span>Status</span>
        <?= Html::dropDownList('status', $search->status, $rotuloStatus, ['class' => 'form-select', 'prompt' => 'Todos']) ?>
    </label>
    <?= Html::submitButton('Filtrar', ['class' => 'btn-lime']) ?>
</form>
```

- [ ] **Step 2: Reescrever `index.php`**

Substituir todo o conteúdo de `views/card/index.php` por:

```php
<?php
/** @var yii\web\View $this */
/** @var app\models\CardSearch $search */
/** @var array $linhas */
/** @var array $obras */
/** @var array $profsOpt */

use yii\helpers\Html;
use yii\helpers\Url;
use app\models\Quinzena;

$this->title = 'Cards (Fechamento)';
$temPeriodo = $search->data_inicial && $search->data_final && !$search->hasErrors();
$rotuloStatus = ['pendente' => 'Pendente', 'rascunho' => 'Rascunho', 'fechado' => 'Fechado', 'pago' => 'Pago'];
$money = fn($v) => 'R$ ' . number_format((float) $v, 2, ',', '.');
?>
<div class="list-toolbar<?= $temPeriodo ? '' : ' filters-open' ?>">
    <button type="button" class="filter-toggle"
        onclick="this.setAttribute('aria-expanded', this.closest('.list-toolbar').classList.toggle('filters-open'));">
        <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M7 12h10M10 18h4"/></svg>
        Filtros
    </button>
    <div class="filter-bar"><?= $this->render('_search', ['search' => $search, 'obras' => $obras, 'profsOpt' => $profsOpt]) ?></div>
</div>

<?php if (!$temPeriodo): ?>
    <p class="em-breve">Informe o período (data inicial e final) para ver os cards.</p>
    <?php foreach ($search->getFirstErrors() as $erro): ?>
        <p class="muted"><?= Html::encode($erro) ?></p>
    <?php endforeach; ?>
<?php else: ?>
    <div class="only-desktop">
        <table class="data-table">
            <thead><tr><th>Profissional / Função</th><th>Quinzena</th><th>Obras</th><th>Diárias</th><th>Valor a receber</th><th>Valor da nota</th><th>Status</th><th>Docs</th></tr></thead>
            <tbody>
            <?php foreach ($linhas as $l): $p = $l['profissional']; $card = $l['card']; ?>
                <tr>
                    <td><a href="<?= Url::to(['card/fechar', 'profissional_id' => $p->id, 'quinzena' => $l['quinzena']]) ?>"><?= Html::encode($p->nome) ?></a><br><span class="muted"><?= Html::encode($p->funcao->nome ?? '') ?></span></td>
                    <td><?= Html::encode(Quinzena::rotulo($l['quinzena'])) ?></td>
                    <td><?= $card ? Html::encode(implode(', ', $card->obrasTrabalhadas())) : '—' ?></td>
                    <td><?= $l['diarias'] ?></td>
                    <td><?= $card ? $money($card->total_liquido) : '—' ?></td>
                    <td><?= $card ? $money($card->total_nota) : '—' ?></td>
                    <td><span class="badge-status<?= $l['status'] === 'pendente' ? ' off' : '' ?>"><?= $rotuloStatus[$l['status']] ?></span></td>
                    <td>
                        <?php foreach (($card ? $card->documentosPagamento() : []) as $doc): ?>
                            <?= Html::a('⬇', ['card/baixar-doc', 'id' => $card->id, 'doc_id' => $doc->id], ['target' => '_blank', 'title' => Html::encode($doc->nome_original ?: 'documento')]) ?>
                        <?php endforeach; ?>
                    </td>
                </tr>
            <?php endforeach; ?>
            <?php if (!$linhas): ?><tr><td colspan="8" class="table-empty">Nenhum card no período/filtros.</td></tr><?php endif; ?>
            </tbody>
        </table>
    </div>

    <div class="only-mobile">
        <div class="card-list">
            <?php foreach ($linhas as $l): $p = $l['profissional']; $card = $l['card']; ?>
                <a class="entity-card" href="<?= Url::to(['card/fechar', 'profissional_id' => $p->id, 'quinzena' => $l['quinzena']]) ?>">
                    <div><h3><?= Html::encode($p->nome) ?></h3>
                        <div class="meta"><?= Html::encode(Quinzena::rotulo($l['quinzena'])) ?> · <?= $l['diarias'] ?> diárias<?= $card && $card->obrasTrabalhadas() ? ' · ' . Html::encode(implode(', ', $card->obrasTrabalhadas())) : '' ?></div>
                        <?php if ($card): ?><div class="meta">A receber <?= $money($card->total_liquido) ?> · Nota <?= $money($card->total_nota) ?></div><?php endif; ?></div>
                    <span class="badge-status<?= $l['status'] === 'pendente' ? ' off' : '' ?>"><?= $rotuloStatus[$l['status']] ?></span>
                </a>
            <?php endforeach; ?>
            <?php if (!$linhas): ?><p class="em-breve">Nenhum card no período/filtros.</p><?php endif; ?>
        </div>
    </div>
<?php endif; ?>
```

- [ ] **Step 3: Verificação manual**

`card/index`: sem período → pede período; com período → colunas Quinzena, Obras, Valor a receber, Valor da nota, Status e ícone(s) de download em cards pagos com documentos.

- [ ] **Step 4: Commit**

```bash
git add views/card/_search.php views/card/index.php
git commit -m "feat(card): filtro por periodo e lista enriquecida (quinzena/obras/docs)"
```

---

### Task 5.7: Export Excel da lista de cards

**Files:**
- Modify: `controllers/CardController.php` (novo `actionExport`)
- Modify: `views/card/index.php` (botão exportar)

- [ ] **Step 1: Adicionar import**

No topo de `controllers/CardController.php`, adicionar:

```php
use app\components\ExportHelper;
```

- [ ] **Step 2: Implementar `actionExport`**

Em `controllers/CardController.php`, adicionar o método (logo após `actionIndex`). Reaproveita a montagem de `$linhas` extraindo-a para um helper privado para não duplicar a lógica:

Primeiro, refatorar: extrair o corpo de montagem de `$linhas` do `actionIndex` para um método privado `montarLinhas(CardSearch $search): array` que retorna `$linhas` (mover todo o bloco `if ($search->validate()) { ... }` para lá, retornando `$linhas`). No `actionIndex`, passar a chamar:

```php
        $linhas = $this->montarLinhas($search);
```

Depois adicionar:

```php
    public function actionExport()
    {
        $search = new CardSearch();
        $search->load(Yii::$app->request->queryParams, '');
        $linhas = $this->montarLinhas($search);
        $rows = [];
        foreach ($linhas as $l) {
            $card = $l['card'];
            $rows[] = [
                $l['profissional']->nome,
                $l['profissional']->funcao->nome ?? '',
                Quinzena::rotulo($l['quinzena']),
                $card ? implode(', ', $card->obrasTrabalhadas()) : '',
                $l['diarias'],
                $card ? (float) $card->total_liquido : '',
                $card ? (float) $card->total_nota : '',
                ucfirst($l['status']),
            ];
        }
        return ExportHelper::download('cards',
            ['Profissional', 'Função', 'Quinzena', 'Obras', 'Diárias', 'Valor a receber', 'Valor da nota', 'Status'],
            $rows);
    }
```

- [ ] **Step 3: Botão de exportar na lista**

Em `views/card/index.php`, dentro do bloco `<?php else: ?>` (quando `$temPeriodo`), logo antes da `<div class="only-desktop">`, adicionar:

```php
    <p><?= Html::a('Exportar Excel', array_merge(['card/export'], (array) Yii::$app->request->queryParams), ['class' => 'btn-soft']) ?></p>
```

- [ ] **Step 4: Verificação manual**

Com um período filtrado, clicar "Exportar Excel" e confirmar o download do `.xlsx` com as colunas (Profissional, Função, Quinzena, Obras, Diárias, Valor a receber, Valor da nota, Status).

- [ ] **Step 5: Rodar a suíte completa (regressão final)**

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

- [ ] **Step 6: Commit**

```bash
git add controllers/CardController.php views/card/index.php
git commit -m "feat(card): export Excel da lista de fechamento"
```

---

## Investigação pontual (Ana Paula, id 26)

Após a Fase 2, confirmar o caso real no banco (não bloqueia o restante):

- [ ] **Conferir o card da profissional 26 numa quinzena de exemplo**

```bash
docker compose exec mysql mysql -uroot -p citrus -e \
"SELECT ci.obra_id, ci.quantidade, ci.subtotal FROM card_item ci \
 JOIN card c ON c.id = ci.card_id \
 WHERE c.profissional_id = 26 AND ci.tipo='diaria' AND ci.deleted_at IS NULL;"
```

Comparar com o efetivo ao vivo da mesma quinzena (duas obras esperadas). Se o card estava fechado/pago, o banner de divergência (Task 2.2) já sinaliza; reabrir + "Recalcular" (ou abrir em rascunho, que agora sincroniza via Task 2.1) corrige os dados.

---

## Self-Review (cobertura do spec)

- **Frente 1 (Valor da nota + flag):** Tasks 1.1–1.9 ✓ (migração, cálculo, salvar, tela, JS, PDF)
- **Frente 2 (Bônus de líder):** Task 1.5 ✓ (linha de bônus editável inserida na criação)
- **Frente 3 (Diárias por obra / bug):** Tasks 2.1–2.2 + investigação ✓
- **Frente 4 (Anexos):** Tasks 3.1–3.7 ✓ (tabela, helper imagem→PDF, salvar id-based, actions, UI por item e modal de pagamento, download/excluir)
- **Frente 5 (Histórico com responsável):** Task 4.1 ✓
- **Frente 6 (Filtros + escopo de líder + lista enriquecida):** Tasks 5.1–5.7 ✓ (helpers de escopo aplicados em todas as telas, `Quinzena::entre`, `CardSearch`, index reescrito, lista com quinzena/obras/docs, export)

**Ponto de redação a confirmar com o cliente:** o rótulo do checkbox foi implementado como **"Considerar no valor da nota"** (marcado = soma na nota, ex.: adiantamento). Se o cliente preferir a palavra "Desconsiderar", basta inverter o texto em `views/card/fechar.php` e no `web/js/card.js` (e nenhuma lógica muda).
