# Citrus — Spec 4: Fechamento de Card (Holerite Quinzenal) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Construir o fechamento de card quinzenal: index de pendências por período, montagem do card a partir do efetivo (diárias por obra + bônus/descontos manuais), totais com snapshot, fluxo de status (rascunho→fechado→pago + reaberturas com motivo) e PDF via mPDF.

**Architecture:** Header/itens/histórico: `card` tem muitos `card_item` (diaria/bonus/desconto) e `card_status_historico`. As diárias são derivadas do efetivo (agrupadas por obra) e congeladas como itens ao salvar; bônus/descontos são manuais. Um helper `Quinzena` resolve intervalos de data. `CardController` gateado por `PermissaoBehavior` (tela `card`, só administrativo) cobre index/detalhe/salvar/recalcular/status/PDF.

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

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

**⚠️ Trabalho não-commitado do dev:** há mudanças não-commitadas em main.php, views/funcao|obra|profissional, models/Profissional.php, config/web.php, config/secrets.example.php, ProfissionalSearch.php, commands/SeedController.php, favicons, PRODUCT.md, dados_iniciais.xlsx, views/_pager.php. **NÃO** dê `git add` em nenhum desses. **NÃO** modifique `main.php` (o item de menu é entregue na Task 10 como trecho). Cada task commita só os arquivos que cria/altera.

---

## Mapa de arquivos (todos novos, exceto CardController que substitui o stub)

- `migrations/m260619_000002_create_card_table.php`, `..._000003_create_card_item_table.php`, `..._000004_create_card_status_historico_table.php`
- `models/Quinzena.php`, `models/Card.php`, `models/CardItem.php`, `models/CardStatusHistorico.php`
- `controllers/CardController.php` (substitui stub)
- `views/card/index.php`, `views/card/_search.php`, `views/card/fechar.php`, `views/card/_pdf.php`
- `web/js/card.js` (collapse das diárias)
- `web/css/app.css` (estilos do card — append)
- Tests: `tests/Unit/Models/QuinzenaTest.php`, `tests/Unit/Models/CardTest.php`, `tests/functional/CardCest.php`
- composer: `mpdf/mpdf`

---

## Task 1: Migrations + mPDF

**Files:** Create the 3 migration files; modify `composer.json` (via composer require).

- [ ] **Step 1: `migrations/m260619_000002_create_card_table.php`**
```php
<?php

use yii\db\Migration;

class m260619_000002_create_card_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%card}}', [
            'id' => $this->primaryKey(),
            'profissional_id' => $this->integer()->notNull(),
            'quinzena' => $this->string(9)->notNull(), // AAAA-MM-Q
            'status' => "ENUM('rascunho','fechado','pago') NOT NULL DEFAULT 'rascunho'",
            'total_diarias' => $this->decimal(10, 2)->notNull()->defaultValue(0),
            'total_bonus' => $this->decimal(10, 2)->notNull()->defaultValue(0),
            'total_desconto' => $this->decimal(10, 2)->notNull()->defaultValue(0),
            'total_liquido' => $this->decimal(10, 2)->notNull()->defaultValue(0),
            'pago_em' => $this->date()->null(),
            'pago_por' => $this->integer()->null(),
            'created_at' => $this->integer()->notNull(),
            'updated_at' => $this->integer()->notNull(),
            'deleted_at' => $this->integer()->null(),
        ]);
        $this->createIndex('idx-card-prof-quinzena', '{{%card}}', ['profissional_id', 'quinzena']);
        $this->addForeignKey('fk-card-profissional', '{{%card}}', 'profissional_id', '{{%profissional}}', 'id');
        $this->addForeignKey('fk-card-pagopor', '{{%card}}', 'pago_por', '{{%profissional}}', 'id', 'SET NULL');
    }

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

- [ ] **Step 2: `migrations/m260619_000003_create_card_item_table.php`**
```php
<?php

use yii\db\Migration;

class m260619_000003_create_card_item_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%card_item}}', [
            'id' => $this->primaryKey(),
            'card_id' => $this->integer()->notNull(),
            'tipo' => "ENUM('diaria','bonus','desconto') NOT NULL",
            'obra_id' => $this->integer()->null(),
            'descricao' => $this->string(180)->notNull(),
            'quantidade' => $this->decimal(6, 2)->notNull()->defaultValue(1),
            'valor_unitario' => $this->decimal(10, 2)->notNull()->defaultValue(0),
            'subtotal' => $this->decimal(10, 2)->notNull()->defaultValue(0),
            'created_at' => $this->integer()->notNull(),
            'updated_at' => $this->integer()->notNull(),
            'deleted_at' => $this->integer()->null(),
        ]);
        $this->createIndex('idx-card_item-card', '{{%card_item}}', ['card_id', 'tipo']);
        $this->addForeignKey('fk-card_item-card', '{{%card_item}}', 'card_id', '{{%card}}', 'id', 'CASCADE');
        $this->addForeignKey('fk-card_item-obra', '{{%card_item}}', 'obra_id', '{{%obra}}', 'id', 'SET NULL');
    }

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

- [ ] **Step 3: `migrations/m260619_000004_create_card_status_historico_table.php`**
```php
<?php

use yii\db\Migration;

class m260619_000004_create_card_status_historico_table extends Migration
{
    public function safeUp()
    {
        $this->createTable('{{%card_status_historico}}', [
            'id' => $this->primaryKey(),
            'card_id' => $this->integer()->notNull(),
            'de_status' => $this->string(20)->null(),
            'para_status' => $this->string(20)->notNull(),
            'usuario_id' => $this->integer()->null(),
            'motivo' => $this->string(255)->null(),
            'created_at' => $this->integer()->notNull(),
        ]);
        $this->createIndex('idx-card_hist-card', '{{%card_status_historico}}', ['card_id']);
        $this->addForeignKey('fk-card_hist-card', '{{%card_status_historico}}', 'card_id', '{{%card}}', 'id', 'CASCADE');
    }

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

- [ ] **Step 4: Instalar mPDF + rodar migrations nos dois bancos**
```
docker compose exec php composer require mpdf/mpdf --no-interaction
docker compose exec php php yii migrate --interactive=0
docker compose exec -e MYSQL_DATABASE=citrus_test php php yii migrate --interactive=0
```
Expected: mpdf instalado; 3 migrations aplicadas em cada banco. Verifique: `docker compose exec db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" citrus -e "SHOW TABLES LIKE \"card%\";"'` → card, card_item, card_status_historico.

- [ ] **Step 5: Commit** (composer.json + composer.lock + migrations)
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/migrations/m260619_000002_create_card_table.php citrus/migrations/m260619_000003_create_card_item_table.php citrus/migrations/m260619_000004_create_card_status_historico_table.php citrus/composer.json citrus/composer.lock
git commit -m "feat(citrus): migrations card/card_item/card_status_historico + mpdf

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
Confirm `git show --stat HEAD` lists only those files (no dev files).

---

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

**Files:** Create `models/Quinzena.php`, `tests/Unit/Models/QuinzenaTest.php`

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

use app\models\Quinzena;

class QuinzenaTest extends \Codeception\Test\Unit
{
    public function testIntervaloPrimeira()
    {
        $this->assertEquals(['2026-06-01', '2026-06-15'], Quinzena::intervalo('2026-06-1'));
    }

    public function testIntervaloSegundaMes30()
    {
        $this->assertEquals(['2026-06-16', '2026-06-30'], Quinzena::intervalo('2026-06-2'));
    }

    public function testIntervaloSegundaMes31()
    {
        $this->assertEquals(['2026-07-16', '2026-07-31'], Quinzena::intervalo('2026-07-2'));
    }

    public function testIntervaloFevereiroBissexto()
    {
        $this->assertEquals(['2024-02-16', '2024-02-29'], Quinzena::intervalo('2024-02-2'));
    }

    public function testRotulo()
    {
        $this->assertEquals('01–15/06/2026', Quinzena::rotulo('2026-06-1'));
        $this->assertEquals('16–30/06/2026', Quinzena::rotulo('2026-06-2'));
    }
}
```

- [ ] **Step 2: Run → FAIL** `docker compose exec php vendor/bin/codecept run Unit Models/QuinzenaTest`

- [ ] **Step 3: Implementar `models/Quinzena.php`**
```php
<?php

namespace app\models;

class Quinzena
{
    /** "AAAA-MM-Q" → ['inicio','fim'] Y-m-d (1–15 / 16–último dia). */
    public static function intervalo(string $quinzena): array
    {
        [$ano, $mes, $q] = explode('-', $quinzena);
        $mm = sprintf('%02d', (int) $mes);
        if ((int) $q === 1) {
            return ["$ano-$mm-01", "$ano-$mm-15"];
        }
        $ultimo = (int) date('t', mktime(0, 0, 0, (int) $mes, 1, (int) $ano));
        return ["$ano-$mm-16", "$ano-$mm-" . sprintf('%02d', $ultimo)];
    }

    /** "01–15/06/2026" */
    public static function rotulo(string $quinzena): string
    {
        [$ini, $fim] = self::intervalo($quinzena);
        $di = date('d', strtotime($ini));
        $df = date('d', strtotime($fim));
        $my = date('m/Y', strtotime($ini));
        return "$di–$df/$my";
    }

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

- [ ] **Step 4: Run → PASS (5 testes)** `docker compose exec php vendor/bin/codecept run Unit Models/QuinzenaTest`

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/models/Quinzena.php citrus/tests/Unit/Models/QuinzenaTest.php
git commit -m "feat(citrus): helper Quinzena (intervalo/rotulo/disponiveis)

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

---

## Task 3: Models CardItem, CardStatusHistorico, Card (esqueleto + relações)

**Files:** Create `models/CardItem.php`, `models/CardStatusHistorico.php`, `models/Card.php`

- [ ] **Step 1: `models/CardItem.php`**
```php
<?php

namespace app\models;

class CardItem extends BaseActiveRecord
{
    public const TIPO_DIARIA = 'diaria';
    public const TIPO_BONUS = 'bonus';
    public const TIPO_DESCONTO = 'desconto';

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

    public function rules()
    {
        return [
            [['card_id', 'tipo', 'descricao'], 'required'],
            [['card_id', 'obra_id'], 'integer'],
            [['tipo'], 'in', 'range' => [self::TIPO_DIARIA, self::TIPO_BONUS, self::TIPO_DESCONTO]],
            [['descricao'], 'string', 'max' => 180],
            [['quantidade', 'valor_unitario', 'subtotal'], 'number'],
        ];
    }

    public function getObra()
    {
        return $this->hasOne(Obra::class, ['id' => 'obra_id']);
    }
}
```

- [ ] **Step 2: `models/CardStatusHistorico.php`** (ActiveRecord simples — sem soft-delete/audit)
```php
<?php

namespace app\models;

use yii\db\ActiveRecord;

class CardStatusHistorico extends ActiveRecord
{
    public static function tableName()
    {
        return '{{%card_status_historico}}';
    }

    public function rules()
    {
        return [
            [['card_id', 'para_status', 'created_at'], 'required'],
            [['card_id', 'usuario_id', 'created_at'], 'integer'],
            [['de_status', 'para_status'], 'string', 'max' => 20],
            [['motivo'], 'string', 'max' => 255],
        ];
    }
}
```

- [ ] **Step 3: `models/Card.php`** (esqueleto: consts, tableName, rules, relações; lógica de cálculo/status vem nas Tasks 4 e 5)
```php
<?php

namespace app\models;

class Card extends BaseActiveRecord
{
    public const STATUS_RASCUNHO = 'rascunho';
    public const STATUS_FECHADO = 'fechado';
    public const STATUS_PAGO = 'pago';

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

    public function rules()
    {
        return [
            [['profissional_id', 'quinzena'], 'required'],
            [['profissional_id', 'pago_por'], 'integer'],
            [['quinzena'], 'string', 'max' => 9],
            [['status'], 'in', 'range' => [self::STATUS_RASCUNHO, self::STATUS_FECHADO, self::STATUS_PAGO]],
            [['total_diarias', 'total_bonus', 'total_desconto', 'total_liquido'], 'number'],
            [['pago_em'], 'date', 'format' => 'php:Y-m-d'],
        ];
    }

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

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

    public function getItens()
    {
        return $this->hasMany(CardItem::class, ['card_id' => 'id']);
    }

    public function getHistorico()
    {
        return $this->hasMany(CardStatusHistorico::class, ['card_id' => 'id'])->orderBy(['id' => SORT_ASC]);
    }

    /** Itens de um tipo (não-excluídos). */
    public function itensPorTipo(string $tipo): array
    {
        return $this->getItens()->andWhere(['tipo' => $tipo])->orderBy('id')->all();
    }
}
```

- [ ] **Step 4: Lint**
```
docker compose exec php php -l models/CardItem.php
docker compose exec php php -l models/CardStatusHistorico.php
docker compose exec php php -l models/Card.php
```
Expected: no syntax errors.

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/models/CardItem.php citrus/models/CardStatusHistorico.php citrus/models/Card.php
git commit -m "feat(citrus): models Card/CardItem/CardStatusHistorico (esqueleto + relações)

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

---

## Task 4: Cálculo do card (diárias, totais, abrir rascunho) — TDD

**Files:** Modify `models/Card.php`; Test `tests/Unit/Models/CardTest.php`

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

use app\models\Card;
use app\models\CardItem;
use app\models\EfetivoDia;
use app\models\Efetivo;

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

    /** cria N diárias para o profissional 1 (função diária, valor 0 na fixture admin) numa obra/datas. */
    private function efetivo(int $obra, array $datas, int $prof = 1): void
    {
        foreach ($datas as $d) {
            $dia = EfetivoDia::findOrCreate($obra, $d);
            (new Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => $prof, 'hora_entrada' => '08:00']))->save();
        }
    }

    public function testAbrirRascunhoMontaDiariasPorObra()
    {
        // profissional 2 (função 10 = diaria, valor_diaria 150 na fixture lider)
        $this->efetivo(100, ['2026-12-02', '2026-12-03', '2026-12-04'], 2); // 3 diárias obra 100
        $card = Card::abrirParaEdicao(2, '2026-12-1');
        $this->assertNotNull($card->id);
        $diarias = $card->itensPorTipo(CardItem::TIPO_DIARIA);
        $this->assertCount(1, $diarias); // uma obra
        $this->assertEquals(3, (float) $diarias[0]->quantidade);
        $this->assertEquals(150, (float) $diarias[0]->valor_unitario);
        $this->assertEquals(450, (float) $diarias[0]->subtotal);
        $this->assertEquals(450, (float) $card->total_diarias);
        $this->assertEquals(450, (float) $card->total_liquido);
    }

    public function testTotalLiquidoComBonusEDesconto()
    {
        $this->efetivo(100, ['2026-12-10', '2026-12-11'], 2); // 2 × 150 = 300
        $card = Card::abrirParaEdicao(2, '2026-12-1');
        (new CardItem(['card_id' => $card->id, 'tipo' => 'bonus', 'descricao' => 'Bônus', 'quantidade' => 1, 'valor_unitario' => 100, 'subtotal' => 100]))->save();
        (new CardItem(['card_id' => $card->id, 'tipo' => 'desconto', 'descricao' => 'Adiant.', 'quantidade' => 1, 'valor_unitario' => 50, 'subtotal' => 50]))->save();
        $card->recalcularTotais();
        $this->assertEquals(300, (float) $card->total_diarias);
        $this->assertEquals(100, (float) $card->total_bonus);
        $this->assertEquals(50, (float) $card->total_desconto);
        $this->assertEquals(350, (float) $card->total_liquido); // 300 + 100 - 50
    }

    public function testAbrirReusaCardExistente()
    {
        $this->efetivo(100, ['2026-12-20'], 2);
        $a = Card::abrirParaEdicao(2, '2026-12-1');
        $b = Card::abrirParaEdicao(2, '2026-12-1');
        $this->assertEquals($a->id, $b->id); // único por (prof, quinzena)
    }
}
```

- [ ] **Step 2: Run → FAIL** `docker compose exec php vendor/bin/codecept run Unit Models/CardTest`

- [ ] **Step 3: Implementar em `models/Card.php`** (acrescentar à classe)
```php
    /** Recalcula totais a partir dos itens e persiste. */
    public function recalcularTotais(): void
    {
        $d = $b = $desc = 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; }
        }
        $this->total_diarias = $d;
        $this->total_bonus = $b;
        $this->total_desconto = $desc;
        $this->total_liquido = $d + $b - $desc;
        $this->save(false, ['total_diarias', 'total_bonus', 'total_desconto', 'total_liquido', 'updated_at']);
    }

    /** (Re)monta as diárias a partir do efetivo. Só em rascunho. Preserva bônus/descontos. */
    public function recalcularDiarias(): void
    {
        if ($this->status !== self::STATUS_RASCUNHO) {
            return;
        }
        // diárias são derivadas: remove e recria
        CardItem::deleteAll(['card_id' => $this->id, 'tipo' => CardItem::TIPO_DIARIA]);
        $prof = $this->profissional;
        [$ini, $fim] = Quinzena::intervalo($this->quinzena);

        if ($prof->funcao && $prof->funcao->tipo_remuneracao === 'quinzena') {
            $valor = (float) ($prof->funcao->valor_base_quinzena ?? 0);
            $item = new CardItem([
                'card_id' => $this->id, 'tipo' => CardItem::TIPO_DIARIA, 'obra_id' => null,
                'descricao' => 'Quinzena (' . ($prof->funcao->nome ?? '') . ')',
                'quantidade' => 1, 'valor_unitario' => $valor, 'subtotal' => $valor,
            ]);
            $item->save();
        } else {
            $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 = $r->efetivoDia->obra_id;
                $porObra[$oid] = ($porObra[$oid] ?? 0) + 1;
            }
            foreach ($porObra as $obraId => $count) {
                $obra = Obra::findOne($obraId);
                $valor = (float) $prof->valor_diaria;
                $item = new CardItem([
                    'card_id' => $this->id, 'tipo' => CardItem::TIPO_DIARIA, 'obra_id' => $obraId,
                    'descricao' => $obra->nome ?? '', 'quantidade' => $count,
                    'valor_unitario' => $valor, 'subtotal' => $count * $valor,
                ]);
                $item->save();
            }
        }
        $this->recalcularTotais();
    }

    /** Abre (cria se não existir) o card rascunho de (profissional, quinzena), já com as diárias. */
    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();
        }
        return $card;
    }

    /** Registros de efetivo (ao vivo) do profissional/quinzena numa obra — para o collapse. */
    public function efetivosDaObra(?int $obraId): array
    {
        [$ini, $fim] = Quinzena::intervalo($this->quinzena);
        return Efetivo::find()->joinWith('efetivoDia')
            ->where(['efetivo.profissional_id' => $this->profissional_id])
            ->andWhere(['efetivo_dia.obra_id' => $obraId])
            ->andWhere(['between', 'efetivo_dia.data', $ini, $fim])
            ->andWhere(['efetivo_dia.deleted_at' => null])
            ->orderBy(['efetivo_dia.data' => SORT_ASC])->all();
    }
```

- [ ] **Step 4: Run → PASS (3 testes)** `docker compose exec php vendor/bin/codecept run Unit Models/CardTest`
Then full suite: `docker compose exec php vendor/bin/codecept run`.

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/models/Card.php citrus/tests/Unit/Models/CardTest.php
git commit -m "feat(citrus): cálculo do card (diárias por obra, totais, abrir rascunho)

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

---

## Task 5: Transições de status + histórico (TDD)

**Files:** Modify `models/Card.php`; add tests to `tests/Unit/Models/CardTest.php`

- [ ] **Step 1: Acrescentar testes a `CardTest`**
```php
    public function testTransicaoValidaGravaHistorico()
    {
        $this->efetivo(100, ['2026-12-05'], 2);
        $card = Card::abrirParaEdicao(2, '2026-12-1');
        $this->assertTrue($card->mudarStatus(Card::STATUS_FECHADO, null));
        $this->assertEquals('fechado', $card->status);
        $hist = $card->getHistorico()->all();
        $this->assertNotEmpty($hist);
        $this->assertEquals('fechado', end($hist)->para_status);
    }

    public function testTransicaoInvalidaRejeitada()
    {
        $this->efetivo(100, ['2026-12-06'], 2);
        $card = Card::abrirParaEdicao(2, '2026-12-1'); // rascunho
        $this->assertFalse($card->mudarStatus(Card::STATUS_PAGO, null)); // rascunho→pago não permitido
        $this->assertArrayHasKey('status', $card->errors);
    }

    public function testReaberturaExigeMotivo()
    {
        $this->efetivo(100, ['2026-12-07'], 2);
        $card = Card::abrirParaEdicao(2, '2026-12-1');
        $card->mudarStatus(Card::STATUS_FECHADO, null);
        $this->assertFalse($card->mudarStatus(Card::STATUS_RASCUNHO, null)); // reabrir sem motivo
        $this->assertArrayHasKey('motivo', $card->errors);
        $this->assertTrue($card->mudarStatus(Card::STATUS_RASCUNHO, 'Erro de lançamento'));
        $this->assertEquals('rascunho', $card->status);
    }

    public function testPagoRegistraData()
    {
        $this->efetivo(100, ['2026-12-08'], 2);
        $card = Card::abrirParaEdicao(2, '2026-12-1');
        $card->mudarStatus(Card::STATUS_FECHADO, null);
        $this->assertTrue($card->mudarStatus(Card::STATUS_PAGO, null));
        $this->assertNotNull($card->pago_em);
    }
```

- [ ] **Step 2: Run → FAIL** `docker compose exec php vendor/bin/codecept run Unit Models/CardTest`

- [ ] **Step 3: Implementar em `models/Card.php`**
Adicione `use Yii;` no topo (se não houver) e os métodos:
```php
    public const TRANSICOES = [
        self::STATUS_RASCUNHO => [self::STATUS_FECHADO],
        self::STATUS_FECHADO => [self::STATUS_PAGO, self::STATUS_RASCUNHO],
        self::STATUS_PAGO => [self::STATUS_FECHADO],
    ];

    public function podeMudarPara(string $novo): bool
    {
        return in_array($novo, self::TRANSICOES[$this->status] ?? [], true);
    }

    /** Reabertura = voltar status (fechado→rascunho ou pago→fechado). Exige motivo. */
    private function ehReabertura(string $novo): bool
    {
        return ($this->status === self::STATUS_FECHADO && $novo === self::STATUS_RASCUNHO)
            || ($this->status === self::STATUS_PAGO && $novo === self::STATUS_FECHADO);
    }

    public function mudarStatus(string $novo, ?string $motivo): bool
    {
        if (!$this->podeMudarPara($novo)) {
            $this->addError('status', 'Transição de status inválida.');
            return false;
        }
        if ($this->ehReabertura($novo) && empty(trim((string) $motivo))) {
            $this->addError('motivo', 'Informe o motivo da reabertura.');
            return false;
        }
        $de = $this->status;
        $this->status = $novo;
        if ($novo === self::STATUS_PAGO) {
            $this->pago_em = date('Y-m-d');
            $this->pago_por = Yii::$app->has('user', true) && !Yii::$app->user->isGuest ? Yii::$app->user->id : null;
        }
        if ($de === self::STATUS_PAGO && $novo === self::STATUS_FECHADO) {
            $this->pago_em = null;
            $this->pago_por = null;
        }
        $this->save(false); // dispara auditoria global (AuditBehavior)

        $h = new CardStatusHistorico([
            'card_id' => $this->id, 'de_status' => $de, 'para_status' => $novo,
            'usuario_id' => Yii::$app->has('user', true) && !Yii::$app->user->isGuest ? Yii::$app->user->id : null,
            'motivo' => $motivo ?: null, 'created_at' => time(),
        ]);
        $h->save(false);
        return true;
    }
```

- [ ] **Step 4: Run → PASS** `docker compose exec php vendor/bin/codecept run Unit Models/CardTest` (agora 7 testes) + full suite.

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/models/Card.php citrus/tests/Unit/Models/CardTest.php
git commit -m "feat(citrus): transições de status do card + histórico (reabrir exige motivo)

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

---

## Task 6: CardController index + view (pendências por período)

**Files:** Replace `controllers/CardController.php`; Create `views/card/index.php`, `views/card/_search.php`; Test `tests/functional/CardCest.php`

- [ ] **Step 1: Replace `controllers/CardController.php`** (só index + behaviors nesta task; demais actions vêm depois)
```php
<?php

declare(strict_types=1);

namespace app\controllers;

use Yii;
use app\models\Card;
use app\models\Quinzena;
use app\models\Profissional;
use app\models\Efetivo;
use app\components\PermissaoBehavior;
use yii\web\Controller;
use yii\filters\VerbFilter;

class CardController extends Controller
{
    public function behaviors(): array
    {
        return [
            'access' => [
                'class' => PermissaoBehavior::class,
                'acaoMap' => [
                    'fechar' => 'update', 'recalcular' => 'update', 'salvar' => 'update',
                    'mudar-status' => 'update', 'pdf' => 'view',
                ],
            ],
            'verbs' => [
                'class' => VerbFilter::class,
                'actions' => ['recalcular' => ['post'], 'salvar' => ['post'], 'mudar-status' => ['post']],
            ],
        ];
    }

    public function actionIndex()
    {
        $quinzena = Yii::$app->request->get('quinzena');
        $linhas = [];
        if ($quinzena) {
            [$ini, $fim] = Quinzena::intervalo($quinzena);

            // profissionais com efetivo no período (id => nº diárias)
            $efetivoRows = Efetivo::find()->joinWith('efetivoDia')
                ->where(['between', 'efetivo_dia.data', $ini, $fim])
                ->andWhere(['efetivo_dia.deleted_at' => null])
                ->select(['pid' => 'efetivo.profissional_id'])->asArray()->all();
            $diariasPorProf = array_count_values(array_column($efetivoRows, 'pid'));

            // profissionais de função quinzena (pagos independente de efetivo)
            $funcoesQuinzena = \app\models\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' => $quinzena])->indexBy('profissional_id')->all();

            $ids = array_unique(array_merge(array_keys($diariasPorProf), $quinzenistas, array_keys($cards)));
            $profs = Profissional::find()->where(['id' => $ids])->with('funcao')->indexBy('id')->all();

            $rank = [Card::STATUS_RASCUNHO => 1, Card::STATUS_FECHADO => 2, Card::STATUS_PAGO => 3];
            foreach ($ids as $pid) {
                $card = $cards[$pid] ?? null;
                $linhas[] = [
                    'profissional' => $profs[$pid] ?? null,
                    'diarias' => $diariasPorProf[$pid] ?? 0,
                    'card' => $card,
                    'status' => $card ? $card->status : 'pendente',
                    'rank' => $card ? ($rank[$card->status] ?? 9) : 0, // pendente primeiro
                ];
            }
            usort($linhas, fn($a, $b) => [$a['rank'], (string) ($a['profissional']->nome ?? '')]
                <=> [$b['rank'], (string) ($b['profissional']->nome ?? '')]);
        }

        return $this->render('index', [
            'quinzena' => $quinzena,
            'linhas' => $linhas,
            'optQuinzenas' => Quinzena::disponiveis(),
        ]);
    }
}
```

- [ ] **Step 2: `views/card/_search.php`**
```php
<?php
/** @var string|null $quinzena */
/** @var array $optQuinzenas */
use yii\bootstrap5\Html;
?>
<form method="get" action="<?= \yii\helpers\Url::to(['card/index']) ?>" class="filter-form filter-form--inline">
    <label class="filter-field">
        <span>Quinzena *</span>
        <?= Html::dropDownList('quinzena', $quinzena, $optQuinzenas, ['class' => 'form-select', 'prompt' => 'Selecione...']) ?>
    </label>
    <?= Html::submitButton('Filtrar', ['class' => 'btn-lime']) ?>
</form>
```

- [ ] **Step 3: `views/card/index.php`**
```php
<?php
/** @var yii\web\View $this */
/** @var string|null $quinzena */
/** @var array $linhas */
/** @var array $optQuinzenas */

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

$this->title = 'Cards (Fechamento)';
$badge = ['pendente' => 'badge-status off', 'rascunho' => 'badge-status', 'fechado' => 'badge-status', 'pago' => 'badge-status'];
$rotuloStatus = ['pendente' => 'Pendente', 'rascunho' => 'Rascunho', 'fechado' => 'Fechado', 'pago' => 'Pago'];
?>
<div class="list-toolbar">
    <div class="filter-bar"><?= $this->render('_search', ['quinzena' => $quinzena, 'optQuinzenas' => $optQuinzenas]) ?></div>
</div>

<?php if (!$quinzena): ?>
    <p class="em-breve">Selecione uma quinzena para ver os cards do período.</p>
<?php else: ?>
    <p class="efetivo-lista-titulo"><?= Html::encode(Quinzena::rotulo($quinzena)) ?></p>

    <div class="only-desktop">
        <table class="data-table">
            <thead><tr><th>Profissional / Função</th><th>Diárias</th><th>Total líquido</th><th>Status</th></tr></thead>
            <tbody>
            <?php foreach ($linhas as $l): $p = $l['profissional']; ?>
                <tr onclick="window.location='<?= Url::to(['card/fechar', 'profissional_id' => $p->id, 'quinzena' => $quinzena]) ?>'">
                    <td><?= Html::encode($p->nome ?? '—') ?><br><span class="muted"><?= Html::encode($p->funcao->nome ?? '') ?></span></td>
                    <td><?= $l['diarias'] ?></td>
                    <td><?= $l['card'] ? 'R$ ' . number_format((float) $l['card']->total_liquido, 2, ',', '.') : '—' ?></td>
                    <td><span class="<?= $badge[$l['status']] ?>"><?= $rotuloStatus[$l['status']] ?></span></td>
                </tr>
            <?php endforeach; ?>
            <?php if (!$linhas): ?><tr><td colspan="4" class="table-empty">Nenhum profissional com efetivo nesta quinzena.</td></tr><?php endif; ?>
            </tbody>
        </table>
    </div>

    <div class="only-mobile">
        <div class="card-list">
            <?php foreach ($linhas as $l): $p = $l['profissional']; ?>
                <a class="entity-card" href="<?= Url::to(['card/fechar', 'profissional_id' => $p->id, 'quinzena' => $quinzena]) ?>">
                    <div><h3><?= Html::encode($p->nome ?? '—') ?></h3>
                        <div class="meta"><?= Html::encode($p->funcao->nome ?? '') ?> · <?= $l['diarias'] ?> diárias</div></div>
                    <span class="<?= $badge[$l['status']] ?>"><?= $rotuloStatus[$l['status']] ?></span>
                </a>
            <?php endforeach; ?>
            <?php if (!$linhas): ?><p class="em-breve">Nenhum profissional com efetivo nesta quinzena.</p><?php endif; ?>
        </div>
    </div>
<?php endif; ?>
```

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

namespace app\tests\functional;

use app\tests\Support\FunctionalTester;

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

    public function indexExigePeriodoEListaPendentes(FunctionalTester $I)
    {
        // efetivo p/ profissional 2 numa quinzena
        $dia = \app\models\EfetivoDia::findOrCreate(100, '2027-03-03');
        (new \app\models\Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']))->save();

        $I->amLoggedInAs(\app\models\Profissional::findOne(1)); // admin
        $I->amOnRoute('card/index');
        $I->see('Selecione uma quinzena'); // sem período

        $I->amOnRoute('card/index', ['quinzena' => '2027-03-1']);
        $I->seeResponseCodeIs(200);
        $I->see('Líder Um');   // profissional 2 com efetivo no período
        $I->see('Pendente');   // ainda sem card
    }

    public function naoAdminRecebe403(FunctionalTester $I)
    {
        $I->amLoggedInAs(\app\models\Profissional::findOne(2)); // líder
        $I->amOnRoute('card/index', ['quinzena' => '2027-03-1']);
        $I->seeResponseCodeIs(403);
    }
}
```

- [ ] **Step 5: Run + suíte**
```
docker compose exec php vendor/bin/codecept run functional CardCest
docker compose exec php vendor/bin/codecept run
```
Expected: verde.

- [ ] **Step 6: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/controllers/CardController.php citrus/views/card/index.php citrus/views/card/_search.php citrus/tests/functional/CardCest.php
git commit -m "feat(citrus): index de cards por período (pendentes primeiro)

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

---

## Task 7: Detalhe do card (fechar/salvar/recalcular) + collapse

**Files:** Modify `controllers/CardController.php`; Create `views/card/fechar.php`, `web/js/card.js`; add to `tests/functional/CardCest.php`; append CSS.

- [ ] **Step 1: Actions no `CardController`** (após actionIndex)
```php
    public function actionFechar(int $profissional_id, string $quinzena)
    {
        $card = \app\models\Card::abrirParaEdicao($profissional_id, $quinzena);
        return $this->render('fechar', ['card' => $card]);
    }

    public function actionRecalcular(int $id)
    {
        $card = $this->findCard($id);
        $card->recalcularDiarias();
        Yii::$app->session->setFlash('success', 'Diárias recalculadas a partir do efetivo.');
        return $this->redirect(['fechar', 'profissional_id' => $card->profissional_id, 'quinzena' => $card->quinzena]);
    }

    /** Salva bônus/descontos em lote (substitui os manuais) e recalcula totais. Só em rascunho. */
    public function actionSalvar(int $id)
    {
        $card = $this->findCard($id);
        if ($card->status !== \app\models\Card::STATUS_RASCUNHO) {
            Yii::$app->session->setFlash('error', 'Só é possível editar um card em rascunho.');
            return $this->redirect(['fechar', 'profissional_id' => $card->profissional_id, 'quinzena' => $card->quinzena]);
        }
        // remove bônus/descontos atuais e recria a partir do POST (arrays PARALELOS, pareados por índice)
        \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 = (float) str_replace(['.', ','], ['', '.'], (string) ($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();
            }
        }
        // permite ajustar quantidade das diárias (ex.: meia diária)
        foreach ((array) Yii::$app->request->post('diaria_qtd', []) as $itemId => $qtd) {
            $item = \app\models\CardItem::findOne(['id' => (int) $itemId, 'card_id' => $card->id, 'tipo' => 'diaria']);
            if ($item) {
                $item->quantidade = (float) str_replace(',', '.', (string) $qtd);
                $item->subtotal = $item->quantidade * (float) $item->valor_unitario;
                $item->save();
            }
        }
        $card->recalcularTotais();
        Yii::$app->session->setFlash('success', 'Card salvo.');
        return $this->redirect(['fechar', 'profissional_id' => $card->profissional_id, 'quinzena' => $card->quinzena]);
    }

    protected function findCard(int $id): \app\models\Card
    {
        $card = \app\models\Card::findOne($id);
        if (!$card) { throw new \yii\web\NotFoundHttpException('Card não encontrado.'); }
        return $card;
    }
```

- [ ] **Step 2: `views/card/fechar.php`**
```php
<?php
/** @var yii\web\View $this */
/** @var app\models\Card $card */

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

$this->title = 'Card · ' . ($card->profissional->nome ?? '');
$p = $card->profissional;
$rascunho = $card->status === \app\models\Card::STATUS_RASCUNHO;
$money = fn($v) => 'R$ ' . number_format((float) $v, 2, ',', '.');
$hm = fn($t) => $t ? substr((string) $t, 0, 5) : '—';
$rotuloStatus = ['rascunho' => 'Rascunho', 'fechado' => 'Fechado', 'pago' => 'Pago'];
$this->registerJsFile('@web/js/card.js', ['depends' => [\app\assets\AppAsset::class]]);
?>
<?php foreach (['success' => 'alert-success', 'error' => 'alert-danger'] as $k => $cls): ?>
    <?php if (Yii::$app->session->hasFlash($k)): ?><div class="alert <?= $cls ?>"><?= Html::encode(Yii::$app->session->getFlash($k)) ?></div><?php endif; ?>
<?php endforeach; ?>

<div class="card-fechar">
  <div class="card-head">
    <div>
      <h2><?= Html::encode($p->nome ?? '') ?></h2>
      <div class="muted"><?= Html::encode($p->funcao->nome ?? '') ?> · <?= Html::encode($p->cpf) ?><?= $p->cnpj ? ' · ' . Html::encode($p->cnpj) : '' ?> · <?= Html::encode(Quinzena::rotulo($card->quinzena)) ?></div>
    </div>
    <span class="badge-status<?= $card->status === 'rascunho' ? '' : '' ?>"><?= $rotuloStatus[$card->status] ?></span>
  </div>

  <?php $form = \yii\bootstrap5\ActiveForm::begin(['action' => ['card/salvar', 'id' => $card->id], 'method' => 'post', 'id' => 'card-form']); ?>

  <h3 class="efetivo-lista-titulo">Diárias por obra</h3>
  <table class="data-table">
    <thead><tr><th>Obra</th><th>Nº diárias</th><th>Valor unit.</th><th style="text-align:right">Subtotal</th></tr></thead>
    <tbody>
    <?php foreach ($card->itensPorTipo(CardItem::TIPO_DIARIA) as $it): ?>
      <tr>
        <td><?= Html::encode($it->descricao) ?></td>
        <td>
          <?php if ($rascunho && $it->obra_id): ?>
            <a href="#" class="card-collapse-toggle" data-target="ef-<?= $it->id ?>"><?= (float) $it->quantidade ?> ▾</a>
            <?= Html::input('text', "diaria_qtd[$it->id]", (float) $it->quantidade, ['class' => 'form-control form-control-sm', 'style' => 'width:70px;display:inline-block;margin-left:8px']) ?>
          <?php else: ?>
            <?php if ($it->obra_id): ?><a href="#" class="card-collapse-toggle" data-target="ef-<?= $it->id ?>"><?= (float) $it->quantidade ?> ▾</a><?php else: ?><?= (float) $it->quantidade ?><?php endif; ?>
          <?php endif; ?>
        </td>
        <td><?= $money($it->valor_unitario) ?></td>
        <td style="text-align:right"><?= $money($it->subtotal) ?></td>
      </tr>
      <?php if ($it->obra_id): ?>
        <tr class="card-collapse" id="ef-<?= $it->id ?>" hidden><td colspan="4">
          <table class="data-table-inner"><thead><tr><th>Data</th><th>Entrada</th><th>Saída</th><th>Observação</th></tr></thead><tbody>
          <?php foreach ($card->efetivosDaObra($it->obra_id) as $e): ?>
            <tr><td><?= Yii::$app->formatter->asDate($e->efetivoDia->data) ?></td><td><?= $hm($e->hora_entrada) ?></td><td><?= $hm($e->hora_saida) ?></td><td class="muted"><?= Html::encode((string) $e->observacao) ?></td></tr>
          <?php endforeach; ?>
          </tbody></table>
        </td></tr>
      <?php endif; ?>
    <?php endforeach; ?>
    <tr><td colspan="3"><strong>Base de diárias</strong></td><td style="text-align:right"><strong><?= $money($card->total_diarias) ?></strong></td></tr>
    </tbody>
  </table>

  <?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[]", number_format((float) $it->subtotal, 2, ',', '.'), ['class' => 'form-control', 'style' => 'width:130px', 'readonly' => !$rascunho]) ?>
        </div>
      <?php endforeach; ?>
    </div>
  <?php endforeach; ?>

  <div class="card-total">
    <strong>Total líquido</strong>
    <strong class="card-total-v"><?= $money($card->total_liquido) ?></strong>
  </div>
  <div class="muted" style="text-align:right;font-size:11px"><?= $money($card->total_diarias) ?> + <?= $money($card->total_bonus) ?> − <?= $money($card->total_desconto) ?></div>

  <?php if ($rascunho): ?>
    <div class="form-actions">
      <?= Html::a('↻ Recalcular', ['card/recalcular', 'id' => $card->id], ['class' => 'btn-soft', 'data' => ['method' => 'post']]) ?>
      <?= Html::submitButton('Salvar', ['class' => 'btn-soft']) ?>
      <?= Html::a('Gerar PDF', ['card/pdf', 'id' => $card->id], ['class' => 'btn-soft', 'target' => '_blank']) ?>
    </div>
  <?php else: ?>
    <div class="form-actions">
      <?= Html::a('Gerar PDF', ['card/pdf', 'id' => $card->id], ['class' => 'btn-soft', 'target' => '_blank']) ?>
    </div>
  <?php endif; ?>
  <?php \yii\bootstrap5\ActiveForm::end(); ?>

  <?= $this->render('@app/views/card/_status', ['card' => $card]) ?>
</div>
```
> NOTA: o parcial `@app/views/card/_status` (botões de status + histórico) é criado na Task 8. Para esta task, crie um `views/card/_status.php` mínimo: `<?php /** @var app\models\Card $card */ ?>` (vazio) — será preenchido na Task 8. Assim a view renderiza.

- [ ] **Step 3: `views/card/_status.php` mínimo (placeholder p/ Task 8)**
```php
<?php /** @var app\models\Card $card */ ?>
<div id="card-status-placeholder"></div>
```

- [ ] **Step 4: `web/js/card.js`** (collapse + adicionar linha de bônus/desconto)
```javascript
(function () {
  // collapse das diárias
  document.querySelectorAll('.card-collapse-toggle').forEach(function (a) {
    a.addEventListener('click', function (ev) {
      ev.preventDefault();
      var el = document.getElementById(a.getAttribute('data-target'));
      if (el) el.hidden = !el.hidden;
    });
  });
  // adicionar linha de bônus/desconto
  document.querySelectorAll('.card-add-item').forEach(function (a) {
    a.addEventListener('click', function (ev) {
      ev.preventDefault();
      var tipo = a.getAttribute('data-tipo');
      var box = document.querySelector('.card-itens[data-tipo="' + tipo + '"]');
      if (!box) return;
      var row = document.createElement('div');
      row.className = 'card-item-row';
      row.innerHTML = '<input type="text" class="form-control" name="' + tipo + '_descricao[]" placeholder="Descrição">' +
                      '<input type="text" class="form-control" style="width:130px" name="' + tipo + '_valor[]" placeholder="0,00">';
      box.appendChild(row);
    });
  });
})();
```

- [ ] **Step 5: Append CSS to `web/css/app.css`**
```css

/* Card (fechamento) */
.card-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px}
.card-head h2{margin:0;font-size:20px}
.card-collapse-toggle{color:var(--lime-700);text-decoration:none;font-weight:600}
.data-table-inner{width:100%;border-collapse:collapse;font-size:12px;background:var(--bg);border-radius:8px}
.data-table-inner th{color:var(--gray-500);text-align:left;padding:6px 8px;font-size:11px;text-transform:uppercase}
.data-table-inner td{padding:6px 8px;border-top:1px solid var(--border)}
.card-itens{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}
.card-item-row{display:flex;gap:8px}
.card-item-row .form-control{flex:1}
.card-total{display:flex;justify-content:space-between;align-items:center;border-top:2px solid var(--ink);margin-top:16px;padding-top:10px}
.card-total-v{font-size:20px}
.card-add-item{font-size:12px;color:var(--lime-700);text-decoration:none;margin-left:6px}
```

- [ ] **Step 6: Functional tests — adicionar a `CardCest`**
```php
    public function abreCardECalculaDiarias(FunctionalTester $I)
    {
        $dia = \app\models\EfetivoDia::findOrCreate(100, '2027-04-02');
        (new \app\models\Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']))->save();
        $dia2 = \app\models\EfetivoDia::findOrCreate(100, '2027-04-03');
        (new \app\models\Efetivo(['efetivo_dia_id' => $dia2->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']))->save();

        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
        $I->amOnRoute('card/fechar', ['profissional_id' => 2, 'quinzena' => '2027-04-1']);
        $I->seeResponseCodeIs(200);
        $I->see('Diárias por obra');
        $I->see('Edifício Aurora');
        // card criado como rascunho com 2 diárias × 150 = 300
        $card = \app\models\Card::find()->where(['profissional_id' => 2, 'quinzena' => '2027-04-1'])->one();
        $I->assertNotNull($card);
        $I->assertEquals(300, (float) $card->total_diarias);
    }
```

- [ ] **Step 7: Run + suíte**
```
docker compose exec php vendor/bin/codecept run functional CardCest
docker compose exec php vendor/bin/codecept run
```
Expected: verde.

- [ ] **Step 8: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/controllers/CardController.php citrus/views/card/fechar.php citrus/views/card/_status.php citrus/web/js/card.js citrus/web/css/app.css citrus/tests/functional/CardCest.php
git commit -m "feat(citrus): detalhe do card (diárias + collapse, bônus/descontos, recalcular/salvar)

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

---

## Task 8: Ações de status (fechar/pagar/reabrir) + histórico na tela

**Files:** Modify `controllers/CardController.php`; replace `views/card/_status.php`; add to `tests/functional/CardCest.php`

- [ ] **Step 1: `actionMudarStatus` no controller**
```php
    public function actionMudarStatus(int $id)
    {
        $card = $this->findCard($id);
        $novo = (string) Yii::$app->request->post('status');
        $motivo = Yii::$app->request->post('motivo');
        if ($card->mudarStatus($novo, $motivo)) {
            Yii::$app->session->setFlash('success', 'Status atualizado para ' . $novo . '.');
        } else {
            Yii::$app->session->setFlash('error', implode(' ', $card->getErrorSummary(true)));
        }
        return $this->redirect(['fechar', 'profissional_id' => $card->profissional_id, 'quinzena' => $card->quinzena]);
    }
```

- [ ] **Step 2: Replace `views/card/_status.php`** (botões conforme estado + histórico)
```php
<?php
/** @var yii\web\View $this */
/** @var app\models\Card $card */
use yii\helpers\Html;
use yii\helpers\Url;
use app\models\Card;

$btn = function (string $novo, string $label, bool $pedeMotivo) use ($card) {
    $form = Html::beginForm(['card/mudar-status', 'id' => $card->id], 'post', ['class' => 'card-status-form']);
    $form .= Html::hiddenInput('status', $novo);
    if ($pedeMotivo) {
        $form .= Html::textInput('motivo', '', ['placeholder' => 'Motivo da reabertura', 'class' => 'form-control', 'required' => true]);
    }
    $form .= Html::submitButton($label, ['class' => 'btn-lime']);
    $form .= Html::endForm();
    return $form;
};
?>
<div class="card-acoes-status">
    <?php if ($card->status === Card::STATUS_RASCUNHO): ?>
        <?= $btn(Card::STATUS_FECHADO, 'Fechar card', false) ?>
    <?php elseif ($card->status === Card::STATUS_FECHADO): ?>
        <?= $btn(Card::STATUS_PAGO, 'Marcar como pago', false) ?>
        <?= $btn(Card::STATUS_RASCUNHO, 'Reabrir (rascunho)', true) ?>
    <?php elseif ($card->status === Card::STATUS_PAGO): ?>
        <div class="muted">Pago em <?= $card->pago_em ? Yii::$app->formatter->asDate($card->pago_em) : '—' ?><?= $card->pagoPor ? ' por ' . Html::encode($card->pagoPor->nome) : '' ?>.</div>
        <?= $btn(Card::STATUS_FECHADO, 'Reabrir (fechado)', true) ?>
    <?php endif; ?>
</div>

<h3 class="efetivo-lista-titulo">Histórico de status</h3>
<div class="card-list">
    <?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->motivo ? ' · ' . Html::encode($h->motivo) : '' ?></div>
        </div>
    <?php endforeach; ?>
    <?php if (!$card->historico): ?><p class="em-breve">Sem mudanças de status ainda.</p><?php endif; ?>
</div>
```

- [ ] **Step 3: Functional tests — adicionar a `CardCest`**
```php
    public function fluxoFecharPagarReabrir(FunctionalTester $I)
    {
        $dia = \app\models\EfetivoDia::findOrCreate(100, '2027-05-02');
        (new \app\models\Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']))->save();
        $card = \app\models\Card::abrirParaEdicao(2, '2027-05-1');

        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
        // fechar
        $I->sendPost('/index-test.php?r=card%2Fmudar-status&id=' . $card->id, ['status' => 'fechado']);
        $card->refresh();
        $I->assertEquals('fechado', $card->status);
        // reabrir sem motivo → continua fechado
        $I->sendPost('/index-test.php?r=card%2Fmudar-status&id=' . $card->id, ['status' => 'rascunho']);
        $card->refresh();
        $I->assertEquals('fechado', $card->status);
        // reabrir com motivo → rascunho
        $I->sendPost('/index-test.php?r=card%2Fmudar-status&id=' . $card->id, ['status' => 'rascunho', 'motivo' => 'ajuste']);
        $card->refresh();
        $I->assertEquals('rascunho', $card->status);
    }
```
> Se `sendPost` com URL não funcionar no módulo funcional, use `$I->amOnRoute('card/fechar',...)` + `$I->submitForm('.card-status-form', [...])`. O essencial: fechar funciona, reabrir sem motivo é barrado, reabrir com motivo volta a rascunho. A lógica de transição já é coberta pelos testes unit da Task 5 — aqui validamos o controller.

- [ ] **Step 4: Run + suíte**
```
docker compose exec php vendor/bin/codecept run functional CardCest
docker compose exec php vendor/bin/codecept run
```
Expected: verde.

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/controllers/CardController.php citrus/views/card/_status.php citrus/tests/functional/CardCest.php
git commit -m "feat(citrus): ações de status do card (fechar/pagar/reabrir) + histórico na tela

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

---

## Task 9: PDF (mPDF)

**Files:** Modify `controllers/CardController.php`; Create `views/card/_pdf.php`; add to `tests/functional/CardCest.php`

- [ ] **Step 1: `actionPdf` no controller**
```php
    public function actionPdf(int $id)
    {
        $card = $this->findCard($id);
        $html = $this->renderPartial('_pdf', ['card' => $card]);
        $mpdf = new \Mpdf\Mpdf(['tempDir' => Yii::getAlias('@runtime/mpdf')]);
        $mpdf->WriteHTML($html);
        $nome = 'card-' . preg_replace('/\\W+/', '-', (string) ($card->profissional->nome ?? 'card')) . '-' . $card->quinzena;
        Yii::$app->response->format = \yii\web\Response::FORMAT_RAW;
        Yii::$app->response->headers->set('Content-Type', 'application/pdf');
        Yii::$app->response->headers->set('Content-Disposition', "inline; filename=\"{$nome}.pdf\"");
        return $mpdf->Output($nome . '.pdf', \Mpdf\Output\Destination::STRING_RETURN);
    }
```
(Ensure `@runtime/mpdf` is writable; mPDF creates temp files there. `runtime/` já é gitignored.)

- [ ] **Step 2: `views/card/_pdf.php`** (HTML simples para o mPDF)
```php
<?php
/** @var app\models\Card $card */
use app\models\Quinzena;
use app\models\CardItem;
$p = $card->profissional;
$money = fn($v) => 'R$ ' . number_format((float) $v, 2, ',', '.');
$logo = Yii::getAlias('@webroot/img/logo.png');
?>
<style>
  body{font-family:sans-serif;font-size:12px;color:#111}
  h1{font-size:16px;margin:0 0 4px}
  .muted{color:#666;font-size:11px}
  table{width:100%;border-collapse:collapse;margin:10px 0}
  th,td{text-align:left;padding:6px;border-bottom:1px solid #ddd}
  td.r,th.r{text-align:right}
  .tot{border-top:2px solid #000;font-weight:bold}
</style>
<?php if (is_file($logo)): ?><img src="<?= $logo ?>" style="height:38px"><?php endif; ?>
<h1>Card de Pagamento — <?= htmlspecialchars((string) $p->nome) ?></h1>
<div class="muted"><?= htmlspecialchars((string) ($p->funcao->nome ?? '')) ?> · CPF <?= htmlspecialchars((string) $p->cpf) ?><?= $p->cnpj ? ' · CNPJ ' . htmlspecialchars($p->cnpj) : '' ?> · Quinzena <?= htmlspecialchars(Quinzena::rotulo($card->quinzena)) ?></div>

<table>
  <thead><tr><th>Obra / Item</th><th>Qtd</th><th class="r">Valor unit.</th><th class="r">Subtotal</th></tr></thead>
  <tbody>
  <?php foreach ($card->itensPorTipo(CardItem::TIPO_DIARIA) as $it): ?>
    <tr><td><?= htmlspecialchars((string) $it->descricao) ?></td><td><?= (float) $it->quantidade ?></td><td class="r"><?= $money($it->valor_unitario) ?></td><td class="r"><?= $money($it->subtotal) ?></td></tr>
  <?php endforeach; ?>
  <?php foreach ($card->itensPorTipo(CardItem::TIPO_BONUS) as $it): ?>
    <tr><td>Bônus: <?= htmlspecialchars((string) $it->descricao) ?></td><td></td><td></td><td class="r"><?= $money($it->subtotal) ?></td></tr>
  <?php endforeach; ?>
  <?php foreach ($card->itensPorTipo(CardItem::TIPO_DESCONTO) as $it): ?>
    <tr><td>Desconto: <?= htmlspecialchars((string) $it->descricao) ?></td><td></td><td></td><td class="r">- <?= $money($it->subtotal) ?></td></tr>
  <?php endforeach; ?>
  <tr class="tot"><td colspan="3">Total líquido</td><td class="r"><?= $money($card->total_liquido) ?></td></tr>
  </tbody>
</table>
<div class="muted">Status: <?= htmlspecialchars($card->status) ?><?= $card->pago_em ? ' · pago em ' . Yii::$app->formatter->asDate($card->pago_em) : '' ?></div>
```

- [ ] **Step 3: Functional test — adicionar a `CardCest`**
```php
    public function geraPdf(FunctionalTester $I)
    {
        $dia = \app\models\EfetivoDia::findOrCreate(100, '2027-06-02');
        (new \app\models\Efetivo(['efetivo_dia_id' => $dia->id, 'profissional_id' => 2, 'hora_entrada' => '08:00']))->save();
        $card = \app\models\Card::abrirParaEdicao(2, '2027-06-1');

        $I->amLoggedInAs(\app\models\Profissional::findOne(1));
        $I->amOnRoute('card/pdf', ['id' => $card->id]);
        $I->seeResponseCodeIs(200);
        $ct = (string) Yii::$app->response->headers->get('Content-Type');
        $I->assertStringContainsString('pdf', $ct);
    }
```
Add `use Yii;` to the Cest top if not present.

- [ ] **Step 4: Run + suíte**
```
docker compose exec php vendor/bin/codecept run functional CardCest
docker compose exec php vendor/bin/codecept run
```
Expected: verde. (Se o mPDF reclamar do tempDir, garanta `mkdir -p runtime/mpdf` — ou o mPDF cria sozinho; runtime é gravável.)

- [ ] **Step 5: Commit**
```
cd /Users/jhordangabriel/Documents/projects
git add citrus/controllers/CardController.php citrus/views/card/_pdf.php citrus/tests/functional/CardCest.php
git commit -m "feat(citrus): PDF do card via mPDF

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

---

## Task 10: Verificação final + trecho do menu

**Files:** nenhum novo.

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

- [ ] **Step 2: Confirmar isolamento (dev intacto)**
```
cd /Users/jhordangabriel/Documents/projects && git status --short citrus
docker compose exec db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" citrus -e "SELECT COUNT(*) FROM card;"'
```
Os arquivos não-commitados do dev devem continuar lá; o `card` count do dev reflete só uso manual (provavelmente 0). Confirme que nenhum commit do Spec 4 tocou arquivos do dev.

- [ ] **Step 3: Smoke manual**
Logar como administrativo, abrir Cards: selecionar uma quinzena com efetivo → ver pendências (pendentes primeiro) → abrir um → conferir diárias por obra + collapse (data/entrada/saída/observação) → lançar bônus/desconto → Salvar → Recalcular → Fechar → Pagar → Reabrir (com motivo) → Gerar PDF. Conferir desktop e mobile.

- [ ] **Step 4: Trecho do menu (NÃO commitar, NÃO editar main.php)**
O item "Cards" já existe no menu do dev (grupo Financeiro → `card/index`). Se não estiver, inserir manualmente no `$add('Financeiro', [...])`:
```php
$pode('card') ? $item('Cards', 'card', ['card/index'], 'card') : null,
```

---

## Notas de fechamento

- **Snapshot**: diárias são derivadas (recriadas em Recalcular, só em rascunho); bônus/descontos são manuais; totais e itens ficam congelados após fechar.
- **Reabertura** exige motivo e fica registrada em `card_status_historico` + auditoria global.
- **PDF** via mPDF a partir de `views/card/_pdf.php` (logo Citrus incluída).
- **main.php** não é tocado (trabalho não-commitado do dev).
- Branch `citrus-card`; não mergear na master.
- Próximo (Spec 5): Dashboard (KPIs/gráficos/alertas) + Relatórios dinâmicos.
