# 11 — Respostas, Painel do CMS e Exportação

## 1. Onde as respostas moram

Tabela `filled_questionnaires` (schema em `02-MODELAGEM.md` §6):

| Coluna | Conteúdo |
|---|---|
| `id` | PK |
| `ip` | IP de quem preencheu (`inet`) |
| `questionnaire_id` | Questionário respondido (FK, cascade) |
| `answers` | JSON: lista de `{ask_id, value}` |
| `created_at` / `updated_at` | timestamps |

O array `answers` é gerado em `SaveFilledQuestionnaireController`:

```php
$data[] = [
    'ask_id' => $ask->id,
    'value' => $request->get($ask->identifier)
];
```

Exemplo de conteúdo (formato do `cast 'answers' => 'array'`):

```json
[
  {"ask_id": 1, "value": "Maria da Silva"},
  {"ask_id": 2, "value": "123.456.789-00"},
  {"ask_id": 3, "value": ["opcao_a", "opcao_c"]},
  {"ask_id": 4, "value": 42},
  {"ask_id": 5, "value": 17}
]
```

Regras do formato:

- `value` é **sempre** a string crua enviada pelo formulário (`name` = `ask.identifier`).
- Para MULTI_SELECT e MULTI_FILE, `value` é um **array**; para os demais, escalar.
- A chave é o **id numérico** da pergunta, não o `identifier` — o painel resolve `ask` por `ask_id` e o exportador também.
- Perguntas removidas do questionário não podem ser removidas se já houver respostas (ver `05-REGRAS-E-PORTABILIDADE.md` §1.3), o que mantém a integridade referencial — **mas apenas por convenção do código**: não há FK entre o `ask_id` do JSON e `asks.id`.

## 2. Anexos (`ask_files`)

| Momento | Estado da linha |
|---|---|
| Após `POST /formularios/ask-file` | `ask_id` preenchido, `filled_questionnaire_id = null`, `path` gravado |
| Após o envio do questionário | `filled_questionnaire_id` = id da resposta (via `saveFilledQuestionnaireOnAksFile`) |
| Nunca enviado | permanece `null` — **órfão** (sem job de limpeza) |

A URL exibida no painel é montada como `'/storage' . path`:

```php
$item['ask_file_path'] = '/storage' . data_get($file, 'path');
```

e na exportação por `Storage::url($path)`.

## 3. Painel de respostas no CMS

### 3.1 Rotas

| Rota | Controller@ação | Retorno |
|---|---|---|
| `GET /cms/formularios-dinamicos/questionarios-preenchidos` | `FilledQuestionnaireController@index` | View com DataTable (view SQL `listagem_questionarios_preenchidos`) |
| `GET /cms/formularios-dinamicos/questionarios-preenchidos/{id}/respostas` | `@responses` | View `prefecture.dynamics-forms.filled-questionnaire.responses` |
| `GET /cms/formularios-dinamicos/questionarios-preenchidos/{id}/respostas/show` | `@showResponses` | JSON `ShowResponsesResource` |
| `GET /cms/formularios-dinamicos/questionarios-preenchidos/{id}/respostas/individual/{index}` | `@showIndividualResponses` | JSON montado à mão |
| `GET /cms/formularios-dinamicos/questionarios-preenchidos/{id}/respostas/exportar-planilha` | `@exportSpreadSheet` | JSON com o caminho do XLSX |
| `GET /cms/formularios-dinamicos/questionarios-preenchidos/asks?questionnaire_id=N` | `@getAsks` | JSON `Ask\ListResource` (perguntas com arquivos) |

### 3.2 Listagem (DataTable)

`v6/app/Http/Controllers/Prefecture/DatatableController.php:452`:

```php
public function get_questionnaire_list()
{
    $questionnairesView = DB::table("listagem_questionarios")->get();
    return DataTables::of($this->struct_views($questionnairesView, 'questionarios'))->make(true);
}

public function get_filled_questionnaire_list()
{
    $questionnairesView = DB::table("listagem_questionarios_preenchidos")->get();
    return DataTables::of($this->struct_views($questionnairesView, 'questionarios-respondidos'))->make(true);
}
```

A listagem de preenchidos é **por questionário** (a view agrupa por `q.id` e conta `responses`); as respostas individuais são acessadas pelo painel.

Colunas do DataTable de preenchidos (`v6/public/assets/js/custom/contents/formularios-dinamicos/questionarios-preenchidos/datatable.js`): `title`, `asks_count`, `published_at`, `responses` e a ação "Acessar respostas" (`/cms/formularios-dinamicos/questionarios-preenchidos/{id}/respostas`).

Colunas do DataTable de questionários (`v6/public/assets/js/custom/contents/formularios-dinamicos/questionarios/datatable.js`): `title`, `asks_count`, `published_at`, `created_at` e as ações: ativar/desativar (`/cms/formularios-dinamicos/questionarios/atualiza-status`), "Acessar formulário" (`/formularios/{slug}`), ativar/desativar resultados (`/cms/formularios-dinamicos/questionarios/atualiza-resultado`), "Acessar resultados" (`/formularios/resultado/{slug}`), "Acessar respostas" e editar/excluir.

### 3.3 Resumo de um questionário (`showResponses`)

`v6/app/Http/Controllers/Prefecture/DynamicForms/FilledQuestionnaire/FilledQuestionnaireController.php`:

```php
$questionnaire = Questionnaire::query()->with(['categories', 'asks' => function ($query) {
    return $query->with('askFiles');
}, 'filledQuestionnaires'])->findOrFail($questionnaireId);
return new ShowResponsesResource($questionnaire);
```

`v6/app/Http/Resources/Prefecture/DynamicForms/FilledQuestionnaire/ShowResponsesResource.php`:

```php
return [
    'id' => data_get($this, 'id'),
    'categories' => ListWithoutAsksResource::collection(data_get($this, 'categories')),
    'asks' => ListWithFilesResource::collection(data_get($this, 'asks')),
    'responses' => ShowWithoutQuestionnaireResource::collection(data_get($this, 'filledQuestionnaires')),
];
```

Envelope (exemplo real de `ShowResponsesResource`):

```json
{"data":{
  "id":1,
  "categories":[{"id":1,"order":1,"name":"Dados","parent_id":null}],
  "asks":[{"id":1,"ask_category_id":1,"identifier":"1740780000-123456789-65f1c2d3e4f5","order":1,"size":12,"ask_type_id":1,"title":"Nome completo","placeholder":"Digite seu nome","description":null,"required":true,"ask_style_id":null,"value":null,"config":{},"files":[]}],
  "responses":[{"id":7,"answers":[{"ask_id":1,"value":"Maria Silva"}]}]
}}
```

### 3.4 Resposta individual (`showIndividualResponses`)

Este é o único ponto que **traduz** o `value` para algo apresentável por tipo:

```php
$answer = collect($filledQuestionnaires->answers)->map(function ($item) use ($asks) {
    $ask = $asks->where('id', $item['ask_id'])->first();
    $type = $ask->ask_type_id;
    $config = $ask->config;
    if ($type == AskTypesEnum::FILE) {
        $file = $ask->askFiles->where('id', $item['value'])->first();
        $item['ask_file_path'] = '/storage' . data_get($file, 'path');
        $item['ask_file_title'] = data_get($file, 'title');
    }
    if ($type == AskTypesEnum::MULTI_FILE) {
        $files = [];
        foreach ($item['value'] as $fileId) {
            $file = $ask->askFiles->where('id', $fileId)->first();
            if ($file) {
                $files[] = [
                    'ask_file_path' => '/storage' . data_get($file, 'path'),
                    'ask_file_title' => data_get($file, 'title')
                ];
            }
        }
        $item['ask_files'] = $files;
    }

    if ($ask) {
        $item['ask_title'] = $ask->title;
        $item['ask_order'] = $ask->order;
        $item['ask_category_id'] = $ask->ask_category_id;
        $item['ask_style'] = $ask->ask_style_id;
        $item['ask_type'] = $type;
        $item['ask_config'] = $config;
        return $item;
    }
})->filter(function ($item) {
    return !is_null($item);
})->sortBy('ask_order');
```

E agrupa por categoria:

```php
$categories = collect($questionnaire->categories)->map(function ($item) use ($answer) {
    $item = $item->only(['id', 'name', 'order', 'parent_id']);
    $item['answers'] = $answer->where('ask_category_id', $item['id'])->values()->toArray();
    return $item;
})->filter(function ($item) {
    return count($item['answers']) > 0;
})->sortBy('order')->values()->toArray();
```

Formato de saída:

```json
{"data":{
  "categories":[{"id":1,"name":"Dados","order":1,"parent_id":null,"answers":[{"ask_id":1,"value":"Maria Silva","ask_title":"Nome completo","ask_order":1,"ask_category_id":1,"ask_style":null,"ask_type":1,"ask_config":{}}]}],
  "answers":[{"ask_id":1,"value":"Maria Silva","ask_title":"Nome completo","ask_order":1,"ask_category_id":1,"ask_style":null,"ask_type":1,"ask_config":{}}],
  "allResponses": 12,
  "currentResponse": 1
}}
```

Observações:

- O índice da rota é **1-based** (`$filledQuestionnaires[$index - 1]`), mas `currentResponse` devolvido é `index + 1` — inconsistência real.
- `allResponses` é a **quantidade** de respostas (`->count()`), usado pelo JS como total.
- As categorias sem respostas são removidas do agrupamento.

### 3.5 JS do painel

| Arquivo | Papel |
|---|---|
| `v6/public/assets/js/custom/contents/formularios-dinamicos/questionarios-preenchidos/resumo-painel.js` | Monta o painel "resumo" (agregado por pergunta). |
| `v6/public/assets/js/custom/contents/formularios-dinamicos/questionarios-preenchidos/individual-painel.js` | Monta o painel de uma resposta. |
| `v6/public/assets/js/custom/contents/formularios-dinamicos/questionarios-preenchidos/pergunta-painel.js` | Painel por pergunta. |
| `v6/public/assets/js/custom/contents/formularios-dinamicos/questionarios-preenchidos/utils.js` | Utilidades de DOM/estatística. |

## 4. Exportação para planilha

### 4.1 Controller

`FilledQuestionnaireController@exportSpreadSheet`:

```php
$directoryPath = "content/formularios-dinamicos/exports/{$questionnaireId}";
$filePath = "{$directoryPath}/exported-data.xlsx";

if (!Storage::exists($directoryPath)) {
    mkdir(storage_path('app/public/' . $directoryPath), 0755);
}

Excel::store(new ResponsesExport($questionnaireId), $filePath);

return response()->json([
    'success' => true,
    'message' => 'CSV exportado com sucesso!',
    'file_path' => '/storage/' . $filePath,
]);
```

- O arquivo é gravado em `storage/app/public/content/formularios-dinamicos/exports/{questionnaireId}/exported-data.xlsx`.
- A rota é um **GET** que executa a exportação de forma **síncrona**.
- A mensagem diz "CSV", mas o formato é XLSX (bug de texto, `08-GAPS-E-DIVIDAS.md` B10).
- O arquivo anterior é **sobrescrito** a cada exportação (mesmo nome).

### 4.2 `ResponsesExport`

Classe: `v6/app/Exports/DynamicForms/FilledQuestionnaire/ResponsesExport.php` — implementa `FromCollection`, `WithHeadings`, `WithEvents`.

**Cabeçalhos** (uma coluna por pergunta, exceto MULTI_FILE e RADIO, ordenadas por `asks.id`):

```php
$this->headers = $this->questionnaire->asks->filter(function ($item) {
    $type = $item->ask_type_id;
    return !in_array($type, [AskTypesEnum::MULTI_FILE, AskTypesEnum::RADIO]);
})->sortBy('id')->pluck('title')->values()->toArray();
```

**Corpo** (`collection()`), por coluna:

```php
$answers = collect($item->answers)->where('ask_id', $ask->id)->first();
if ($answers) {
    $value = $answers['value'];
    $type = $ask->ask_type_id;
    $config = $ask->config;
    if (in_array($ask->ask_type_id, [AskTypesEnum::MULTI_FILE])) {
        return;                                  // coluna vazia
    }
    if ($type == AskTypesEnum::FILE) {
        $file = $ask->askFiles->where('id', $value)->first();
        return $file ? Storage::url(data_get($file, 'path')) : Storage::url('');
    }
    if ($type == AskTypesEnum::SATISFACTION_LEVEL) {
        $levels = ['Muito Insatisfeito', 'Insatisfeito', 'Neutro', 'Satisfeito', 'Muito Satisfeito'];
        return $levels[$value] ?? $value;
    }
    if ($type == AskTypesEnum::SELECT) {
        $option = collect($config['options'])->where('value', $value)->first();
        return $option['text'] ?? $value;
    }
    if ($type == AskTypesEnum::MULTI_SELECT) {
        return implode(', ', collect($config['options'])
            ->whereIn('value', $value)
            ->pluck('text')
            ->values()
            ->toArray());
    }
    if ($ask->ask_type_id == AskTypesEnum::CHECKBOX) {
        return $value ? 'Sim' : 'Não';
    }
    return $value;
}
```

Resumo da conversão por tipo:

| Tipo | Célula na planilha |
|---|---|
| TEXT / TEXTAREA | valor cru |
| RANGE | valor cru (número) |
| SELECT | **texto** da opção (fallback: o `value`) |
| MULTI_SELECT | textos das opções separados por `, ` |
| CHECKBOX | `Sim` / `Não` |
| SATISFACTION_LEVEL | rótulo fixo do nível escolhido (de "Muito Insatisfeito" a "Muito Satisfeito") |
| FILE | URL pública do arquivo |
| MULTI_FILE | **sem coluna** |
| RADIO | **sem coluna** |

**Formatação** (`registerEvents()` / `AfterSheet`):

- Cabeçalho em negrito, branco, fundo roxo `#4B2E83`, altura 24.
- `setAutoSize(true)` em todas as colunas.
- Células cujo valor começa com `http://` ou `https://` são transformadas em **hiperlink** com o texto "Clique aqui", em azul e sublinhado.

### 4.3 Riscos da exportação

1. **Síncrona**: um questionário com milhares de respostas gera um XLSX em memória dentro do request.
2. **Colunas por título**: o cabeçalho usa `title`; duas perguntas com o **mesmo título** produzem colunas indistinguíveis. A busca interna usa `$this->questionnaire->asks->where('title', $header)->first()` — com títulos repetidos, a **mesma** pergunta é exportada em duas colunas.
3. `sortBy('id')` ordena as colunas por id da pergunta, não por `order` nem por categoria — a ordem visual do formulário não é preservada.
4. Perguntas sem resposta geram célula vazia (o `filter` remove `null`, **não** strings vazias).
5. O arquivo é público em `/storage/content/formularios-dinamicos/exports/{questionnaireId}/exported-data.xlsx` — **qualquer visitante** que descubra o caminho pode baixar a planilha com as respostas (sem controle de acesso).

## 5. Ciclo de vida dos dados

```
CMS: cria questionário ──▶ asks (com identifier único)
                                │
Portal: GET /formularios/slug ─┘
Portal: POST /formularios/ask-file ──▶ ask_files (órfão)
Portal: POST /fill-questionnaire/save ──▶ filled_questionnaires.answers (JSON)
                                        └─▶ ask_files.filled_questionnaire_id
CMS: painel de respostas ──▶ leitura do JSON
CMS: exportar planilha ──▶ XLSX em storage/app/public
CMS: excluir questionário (soft) ──▶ respostas e arquivos permanecem
CMS: excluir respostas (lote) ──▶ ask_files em cascata, arquivos em disco permanecem
```

## 6. Checklist de equivalência da exportação (v7)

- [ ] Manter uma coluna por pergunta, com o **título** como cabeçalho.
- [ ] Manter a exclusão de MULTI_FILE e RADIO (ou decidir conscientemente o contrário).
- [ ] Manter as conversões SELECT/MULTI_SELECT → texto da opção, CHECKBOX → Sim/Não, SATISFACTION_LEVEL → rótulo, FILE → URL.
- [ ] Manter o hiperlink "Clique aqui" para URLs.
- [ ] Mover para job em fila e proteger o download (arquivo com expiração ou rota autenticada).
- [ ] Trocar a busca por `title` por `id` (evita a colisão de títulos).
- [ ] Ordenar as colunas por `order`/categoria (ou documentar a mudança).
