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"
This commit is contained in:
@@ -1,20 +1,44 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V).
|
||||
// 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_ROWS = 200
|
||||
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
|
||||
qi_age_bucket: string | null
|
||||
ip_hash: string | null
|
||||
surveys: { title: string } | { title: string }[] | null
|
||||
answers: { count: number }[]
|
||||
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() {
|
||||
@@ -29,45 +53,125 @@ function formatDate(iso: string): string {
|
||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function dash(value: string | null): string {
|
||||
return value ?? '—'
|
||||
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()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('responses')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
submitted_at,
|
||||
qi_zone,
|
||||
qi_sector,
|
||||
qi_age_bucket,
|
||||
ip_hash,
|
||||
surveys ( title ),
|
||||
answers ( count )
|
||||
`
|
||||
)
|
||||
.order('submitted_at', { ascending: false })
|
||||
.limit(MAX_ROWS)
|
||||
// 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 (error) {
|
||||
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">{error.message}</pre>
|
||||
<pre className="admin-error-detail">{questionsError.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { count: totalCount } = await supabase
|
||||
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 rows = (data ?? []) as unknown as ResponseRow[]
|
||||
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">
|
||||
@@ -75,7 +179,7 @@ export default async function AdminResponsesPage() {
|
||||
<div>
|
||||
<h1 className="admin-title">Control de respuestas</h1>
|
||||
<p className="admin-subtitle">
|
||||
{totalCount ?? 0} respuestas en total · últimas {rows.length} ·
|
||||
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
|
||||
actualizado {formatDate(new Date().toISOString())}
|
||||
</p>
|
||||
</div>
|
||||
@@ -88,29 +192,35 @@ export default async function AdminResponsesPage() {
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Fecha</th>
|
||||
<th>Encuesta</th>
|
||||
<th>Departamento</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>
|
||||
<th className="admin-col-optional">Rango etario</th>
|
||||
<th>Respuestas</th>
|
||||
<th className="admin-col-optional">IP hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const survey = Array.isArray(row.surveys) ? row.surveys[0] : row.surveys
|
||||
const answerCount = row.answers?.[0]?.count ?? 0
|
||||
{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.id}>
|
||||
<td>{i + 1}</td>
|
||||
<td>{formatDate(row.submitted_at)}</td>
|
||||
<td>{survey?.title ?? '—'}</td>
|
||||
<td>{dash(row.qi_zone)}</td>
|
||||
<td className="admin-col-optional">{dash(row.qi_sector)}</td>
|
||||
<td className="admin-col-optional">{dash(row.qi_age_bucket)}</td>
|
||||
<td>{answerCount}</td>
|
||||
<td className="admin-col-optional admin-iphash">
|
||||
{row.ip_hash ? `${row.ip_hash.slice(0, 8)}...` : '—'}
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -765,11 +765,22 @@ body {
|
||||
position: sticky; top: 0; background: var(--color-re-dark); color: #fff;
|
||||
font-weight: 600; text-align: left; padding: 10px 12px; white-space: nowrap;
|
||||
}
|
||||
.admin-table td { padding: 10px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); }
|
||||
.admin-table tbody tr:nth-child(even) { background: var(--color-bg); }
|
||||
.admin-table tbody tr:hover { background: var(--color-primary-light); }
|
||||
.admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); vertical-align: top; }
|
||||
.admin-table tbody tr:last-child td { border-bottom: none; }
|
||||
.admin-iphash { font-family: 'Courier New', monospace; color: var(--color-text-muted); }
|
||||
.admin-table tbody tr.admin-group-even { background: var(--color-bg); }
|
||||
.admin-table tbody tr.admin-group-start td { border-top: 2px solid var(--color-re-dark); }
|
||||
.admin-table tbody tr:hover { background: var(--color-primary-light); }
|
||||
.admin-prompt { color: var(--color-text); }
|
||||
.admin-type-badge {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||
background: var(--color-primary-light); color: var(--color-primary-hover);
|
||||
font-size: 0.7rem; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.admin-nodata {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||
background: var(--color-re-light); color: var(--color-re-dark);
|
||||
opacity: 0.6; font-size: 0.7rem; font-style: italic;
|
||||
}
|
||||
.admin-empty { padding: 24px; text-align: center; color: var(--color-text-muted); }
|
||||
.admin-error { color: var(--color-error); font-weight: 600; }
|
||||
.admin-error-detail {
|
||||
|
||||
Reference in New Issue
Block a user