Files
motor-de-encuestas/app/admin/responses/page.tsx
markosbenitez 157e108322 Los triggers 1.1, 1.6, 1.13 muestran solo Sí · No (sin "Prefiero no decir")
Al responder "No" en las tres, el formulario salta a 6.4 (no muestra secciones 2-5 ni 6.1-6.3)
La pregunta 6.6 aparece antes del cierre
Las preguntas multiple_choice muestran el hint "Podés marcar más de una opción"
2026-06-10 15:49:05 -03:00

237 lines
7.6 KiB
TypeScript

import { createClient } from '@supabase/supabase-js'
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500
const PROMPT_MAX_LEN = 60
interface AllowedQuestion {
id: string
code: string
prompt: string
type: string
position: number
}
interface AnswerRow {
value: unknown
question_id: string
}
interface ResponseRow {
id: string
submitted_at: string
qi_zone: string | null
qi_sector: string | null
answers: AnswerRow[]
}
interface FlatRow {
key: string
submissionNum: number
isFirstInGroup: boolean
submitted_at: string
qi_zone: string | null
qi_sector: string | null
code: string
prompt: string
type: string
value: unknown
}
function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
return createClient(url, serviceKey, { auth: { persistSession: false } })
}
function formatDate(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function truncate(text: string, max = PROMPT_MAX_LEN): string {
return text.length > max ? `${text.slice(0, max)}...` : text
}
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
function renderValue(value: unknown, type: string): string | null {
if (value === null || value === undefined) return null
switch (type) {
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '')
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value)
default:
return String(value)
}
}
export default async function AdminResponsesPage() {
const supabase = getSupabaseAdmin()
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan.
const { data: questionsData, error: questionsError } = await supabase
.from('questions')
.select('id, code, prompt, type, position')
.eq('is_sensitive', false)
.neq('type', 'text_long')
.order('position', { ascending: true })
if (questionsError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{questionsError.message}</pre>
</div>
)
}
const allowedQuestions = (questionsData ?? []) as AllowedQuestion[]
const allowedIds = allowedQuestions.map((q) => q.id)
const questionMap = new Map(allowedQuestions.map((q) => [q.id, q]))
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
// embebido a solo question_id permitidos (consulta, no post-filtro).
const { data: responsesData, error: responsesError } =
allowedIds.length > 0
? await supabase
.from('responses')
.select(
`
id,
submitted_at,
qi_zone,
qi_sector,
answers!inner ( value, question_id )
`
)
.in('answers.question_id', allowedIds)
.order('submitted_at', { ascending: false })
.limit(MAX_SUBMISSIONS)
: { data: [] as ResponseRow[], error: null }
if (responsesError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{responsesError.message}</pre>
</div>
)
}
const responses = (responsesData ?? []) as unknown as ResponseRow[]
// 3. Contadores totales (independientes del límite de 500 submissions)
const { count: totalSubmissions } = await supabase
.from('responses')
.select('*', { count: 'exact', head: true })
const { count: totalAnswers } =
allowedIds.length > 0
? await supabase
.from('answers')
.select('*', { count: 'exact', head: true })
.in('question_id', allowedIds)
: { count: 0 }
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
const rows: FlatRow[] = []
responses.forEach((response, idx) => {
const answers = response.answers
.filter((a) => questionMap.has(a.question_id))
.sort(
(a, b) =>
questionMap.get(a.question_id)!.position - questionMap.get(b.question_id)!.position
)
answers.forEach((a, ai) => {
const q = questionMap.get(a.question_id)!
rows.push({
key: `${response.id}-${a.question_id}`,
submissionNum: idx + 1,
isFirstInGroup: ai === 0,
submitted_at: response.submitted_at,
qi_zone: response.qi_zone,
qi_sector: response.qi_sector,
code: q.code,
prompt: q.prompt,
type: q.type,
value: a.value,
})
})
})
return (
<div className="admin-page" role="main">
<header className="admin-header">
<div>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle">
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
actualizado {formatDate(new Date().toISOString())}
</p>
</div>
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</header>
<div className="admin-table-wrapper">
<table className="admin-table">
<thead>
<tr>
<th>#</th>
<th>Fecha</th>
<th>Código</th>
<th>Pregunta</th>
<th>Tipo</th>
<th>Valor</th>
<th className="admin-col-optional">Departamento</th>
<th className="admin-col-optional">Sector</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const groupClass =
row.submissionNum % 2 === 0 ? 'admin-group-even' : 'admin-group-odd'
const trClass = row.isFirstInGroup ? `${groupClass} admin-group-start` : groupClass
const rendered = renderValue(row.value, row.type)
return (
<tr key={row.key} className={trClass}>
<td>{row.submissionNum}</td>
<td>{row.isFirstInGroup ? formatDate(row.submitted_at) : ''}</td>
<td>{row.code}</td>
<td className="admin-prompt">{truncate(row.prompt)}</td>
<td><span className="admin-type-badge">{row.type}</span></td>
<td>
{rendered === null ? <span className="admin-nodata">sin dato</span> : rendered}
</td>
<td className="admin-col-optional">
{row.isFirstInGroup ? (row.qi_zone ?? '—') : ''}
</td>
<td className="admin-col-optional">
{row.isFirstInGroup ? (row.qi_sector ?? '—') : ''}
</td>
</tr>
)
})}
</tbody>
</table>
{rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p>
)}
</div>
</div>
)
}