# ETAPA 3V-B — Vista de control: tabla plana de respuestas individuales ## 1. Objetivo Reemplazar la vista `/admin/responses` actual (una fila por submission) por una tabla plana con **una fila por respuesta individual** (una fila por cada pregunta respondida), mostrando todos los campos explícitamente incluyendo nulos marcados visualmente. --- ## 2. Contexto previo - La Etapa 3V-A implementó la vista de metadatos de submissions (una fila por response). - El director quiere ver las respuestas individuales a las preguntas, no solo los metadatos. - El esquema relevante: `responses` → `answers` (via response_id) → `questions` (via question_id). - `answers.value` es JSONB. Los tipos de preguntas existentes en el instrumento son: - `single_choice` → value es string: `"Asunción (Capital)"` - `multiple_choice` → value es array: `["opción1","opción2"]` - `rating` → value es número: `3` - `text_short` → value es string corto - `text_long` → **NUNCA mostrar** — regla no negociable (RLS + privacidad) --- ## 3. DECISIONES YA TOMADAS — no consultar de nuevo - Ruta: `/admin/responses` — reemplaza la página anterior completamente. - Sin auth (herramienta interna de control). - Sin paginación por ahora — máximo 500 filas (limit en la query). - Ordenado por `responses.submitted_at DESC`, luego por `questions.position ASC` dentro de cada submission. - Valores nulos mostrados con badge visual "sin dato" — fondo `--color-re-light` (#E7E6E5), texto `--color-re-dark` (#494F4F) opacidad 0.6. - text_long excluido completamente — ni la columna ni el valor aparecen. - Convención CSS: `.admin-*` en `app/globals.css`, igual que la etapa anterior. - Cliente service-role inline en el Server Component, igual que en la versión anterior. --- ## 4. Estructura de la tabla ### 4.1 Encabezado de página - Título: "Control de respuestas" - Subtítulo: total de submissions + total de respuestas individuales + fecha/hora actualización. - Botón "Actualizar" (``). ### 4.2 Columnas de la tabla (una fila por answer) | Columna | Fuente | Notas | |---|---|---| | # submission | índice de la submission (1, 2, 3...) | mismo número para todas las filas de esa submission | | Fecha | `responses.submitted_at` | DD/MM/YYYY HH:mm — solo en la primera fila de cada submission | | Código | `questions.code` | A1, A2, 1.1, 1.6, etc. | | Pregunta | `questions.prompt` | texto de la pregunta, truncado a 60 chars con "..." | | Tipo | `questions.type` | badge con el tipo | | Valor | `answers.value` | renderizado según tipo (ver 4.3) | | Departamento | `responses.qi_zone` | solo en primera fila de cada submission | | Sector | `responses.qi_sector` | solo en primera fila de cada submission | ### 4.3 Renderizado del valor según tipo ```typescript function renderValue(value: unknown, type: string): string { if (value === null || value === undefined) return null // → badge "sin dato" switch(type) { case 'single_choice': case 'text_short': return String(value).replace(/^"|"$/g, '') // quitar comillas del JSONB string case 'multiple_choice': if (Array.isArray(value)) return value.join(', ') return String(value) case 'rating': return String(value) // número, opcionalmente con ★ repetidas case 'text_long': return null // NUNCA mostrar — excluir de la query directamente default: return String(value) } } ``` ### 4.4 Agrupación visual de submissions Las filas de una misma submission deben estar visualmente agrupadas: - Separador visual entre submissions (border-top más pronunciado o fondo alternado por submission). - Fecha, Departamento y Sector solo se muestran en la primera fila de cada submission. - Las filas siguientes de la misma submission muestran esas celdas vacías (o con colspan si es HTML puro). --- ## 5. Query recomendada ```typescript const { data } = await supabase .from('responses') .select(` id, submitted_at, qi_zone, qi_sector, qi_age_bucket, ip_hash, surveys ( title ), answers ( value, questions ( code, prompt, type, position, is_sensitive ) ) `) .order('submitted_at', { ascending: false }) .limit(500) ``` Después en el componente, aplanar el resultado: ```typescript // Para cada response, para cada answer (excluir text_long e is_sensitive): // generar una fila con los datos de la response + los datos de la answer/question const rows = responses.flatMap((response, responseIdx) => response.answers .filter(a => a.questions?.type !== 'text_long' && !a.questions?.is_sensitive) .sort((a, b) => (a.questions?.position ?? 0) - (b.questions?.position ?? 0)) .map((answer, answerIdx) => ({ responseIdx: responseIdx + 1, isFirstInGroup: answerIdx === 0, submitted_at: response.submitted_at, qi_zone: response.qi_zone, qi_sector: response.qi_sector, ip_hash: response.ip_hash, survey_title: response.surveys?.title, code: answer.questions?.code, prompt: answer.questions?.prompt, type: answer.questions?.type, value: answer.value, })) ) ``` --- ## 6. Entregables (DoD) - [ ] Ruta `/admin/responses` renderiza con tabla plana (HTTP 200). - [ ] Una fila por answer, agrupadas visualmente por submission. - [ ] Columnas: # submission, Fecha, Código, Pregunta (truncada), Tipo, Valor, Departamento, Sector. - [ ] `text_long` e `is_sensitive=true` excluidos. - [ ] Valores nulos muestran badge "sin dato" con estilo tenue. - [ ] Fecha, Departamento, Sector solo en primera fila de cada grupo. - [ ] Build exitoso. - [ ] `git diff --name-only HEAD` solo en `app/admin/` y `app/globals.css`. ## 7. Qué reportar ``` Verificado: - Ruta /admin/responses accesible ✓ - Total submissions: [N] · Total filas en tabla: [N] - Tipos de preguntas visibles: [lista] - text_long excluido ✓ / is_sensitive excluido ✓ - Badge "sin dato" visible en campos nulos ✓ / no hay nulos en los datos actuales - Build exitoso ✓ - Diff en scope ✓ Asumido: - [decisiones cosméticas] ``` ## 8. Qué NO hacer - ❌ No mostrar text_long ni is_sensitive bajo ningún concepto. - ❌ No exponer SUPABASE_SERVICE_ROLE_KEY al cliente. - ❌ No tocar el formulario ni el dashboard. - ❌ No agregar dependencias npm. - ❌ No paginar todavía — limit 500 es suficiente para el piloto.