/admin/responses: filtro survey dinámico (select, no hardcodeado), paginación 50/página, link a vista detallada, conteos consistentes con el filtro de preguntas permitidas (evita página fantasma al final). /admin/responses/[id]: respuestas agrupadas por sección (derivado de type='section', igual que el wizard — no existen section_number/ section_title, ver TECH-DEBT-011), instancias bajo "Familiar N". RLS y exclusión de sensibles intactos en ambas vistas. Fixes de revisión antes de cerrar: toggle expandir/colapsar movido a un <button> real (evita anidar el link "Ver" dentro de un role=button), formatDate/renderValue unificados en admin-format.ts, queries independientes en paralelo (Promise.all).
273 lines
9.2 KiB
TypeScript
273 lines
9.2 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { cookies } from 'next/headers'
|
|
import { ResponsesTable, type DisplayRow } from './responses-table'
|
|
import { SurveySelect } from './survey-select'
|
|
import { SignOutButton } from '../sign-out-button'
|
|
import { formatDate, renderValue } from './admin-format'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const PAGE_SIZE = 50
|
|
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 SurveyOption {
|
|
id: string
|
|
title: string
|
|
}
|
|
|
|
async function getSupabaseSession() {
|
|
const cookieStore = await cookies()
|
|
return createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return cookieStore.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
try {
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
cookieStore.set(name, value, options)
|
|
)
|
|
} catch {
|
|
// Server Component — no puede mutar cookies, se ignora
|
|
}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
function truncate(text: string, max = PROMPT_MAX_LEN): string {
|
|
return text.length > max ? `${text.slice(0, max)}...` : text
|
|
}
|
|
|
|
function pageHref(surveyId: string | undefined, page: number): string {
|
|
const params = new URLSearchParams()
|
|
if (surveyId) params.set('survey_id', surveyId)
|
|
params.set('page', String(page))
|
|
return `/admin/responses?${params.toString()}`
|
|
}
|
|
|
|
export default async function AdminResponsesPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ survey_id?: string; page?: string }>
|
|
}) {
|
|
const { survey_id: surveyId, page: pageParam } = await searchParams
|
|
const page = Math.max(1, Number(pageParam) || 1)
|
|
const supabase = await getSupabaseSession()
|
|
|
|
// 0 y 1 no dependen entre sí — en paralelo.
|
|
// 0. Surveys para el filtro — dinámico, no hardcodeado. Solo trae lo que
|
|
// RLS deja ver al usuario autenticado. Si falla, el filtro se degrada a
|
|
// "sin opciones" en vez de tirar abajo la página entera — la lista de
|
|
// respuestas no depende de esta query.
|
|
// 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: surveysData, error: surveysError },
|
|
{ data: questionsData, error: questionsError },
|
|
] = await Promise.all([
|
|
supabase.from('surveys').select('id, title').order('title'),
|
|
supabase
|
|
.from('questions')
|
|
.select('id, code, prompt, type, position')
|
|
.eq('is_sensitive', false)
|
|
.neq('type', 'text_long')
|
|
.order('position', { ascending: true }),
|
|
])
|
|
|
|
const surveys = (surveysData ?? []) as SurveyOption[]
|
|
const selectedSurvey = surveys.find((s) => s.id === surveyId)
|
|
|
|
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).
|
|
// Paginado con .range() — page size 50, no LIMIT fijo sin paginación.
|
|
let responsesQuery = supabase
|
|
.from('responses')
|
|
.select(
|
|
`
|
|
id,
|
|
submitted_at,
|
|
qi_zone,
|
|
qi_sector,
|
|
answers!inner ( value, question_id )
|
|
`
|
|
)
|
|
.in('answers.question_id', allowedIds)
|
|
|
|
if (surveyId) {
|
|
responsesQuery = responsesQuery.eq('survey_id', surveyId)
|
|
}
|
|
|
|
// 3. Contadores totales (independientes de la página actual). submissionsCount
|
|
// usa el MISMO answers!inner + .in() que responsesQuery — si contara todas
|
|
// las responses sin ese filtro, una response sin ningún answer permitido
|
|
// inflaría el total y totalPages quedaría desalineado con lo que .range()
|
|
// realmente puede devolver (página "fantasma" al final).
|
|
let submissionsCountQuery = supabase
|
|
.from('responses')
|
|
.select('*, answers!inner(question_id)', { count: 'exact', head: true })
|
|
.in('answers.question_id', allowedIds)
|
|
|
|
if (surveyId) {
|
|
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
|
|
}
|
|
|
|
let answersCountQuery = supabase
|
|
.from('answers')
|
|
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
|
|
.in('question_id', allowedIds)
|
|
|
|
if (surveyId) {
|
|
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
|
|
}
|
|
|
|
// Las tres son independientes entre sí — en paralelo.
|
|
const [
|
|
{ data: responsesData, error: responsesError },
|
|
{ count: totalSubmissions },
|
|
{ count: totalAnswers },
|
|
] =
|
|
allowedIds.length > 0
|
|
? await Promise.all([
|
|
responsesQuery
|
|
.order('submitted_at', { ascending: false })
|
|
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1),
|
|
submissionsCountQuery,
|
|
answersCountQuery,
|
|
])
|
|
: [{ data: [] as ResponseRow[], error: null }, { count: 0 }, { count: 0 }]
|
|
|
|
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[]
|
|
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
|
|
|
|
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
|
|
const rows: DisplayRow[] = []
|
|
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)!
|
|
const isFirstInGroup = ai === 0
|
|
rows.push({
|
|
key: `${response.id}-${a.question_id}`,
|
|
responseId: response.id,
|
|
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
|
|
isFirstInGroup,
|
|
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
|
|
code: q.code,
|
|
prompt: truncate(q.prompt),
|
|
rendered: renderValue(a.value, q.type),
|
|
zone: isFirstInGroup ? (response.qi_zone ?? '—') : '',
|
|
sector: isFirstInGroup ? (response.qi_sector ?? '—') : '',
|
|
})
|
|
})
|
|
})
|
|
|
|
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 ·{' '}
|
|
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
|
|
{formatDate(new Date().toISOString())}
|
|
</p>
|
|
<p className="admin-subtitle">
|
|
Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
|
|
</p>
|
|
<div className="admin-survey-nav">
|
|
<SurveySelect surveys={surveys} value={surveyId ?? ''} />
|
|
</div>
|
|
{surveysError && (
|
|
<p className="admin-error" role="alert">
|
|
No se pudo cargar la lista de surveys para el filtro — se muestra "Todos".
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="admin-header-actions">
|
|
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
|
<SignOutButton />
|
|
</div>
|
|
</header>
|
|
|
|
<div className="admin-table-wrapper">
|
|
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
|
|
{rows.length === 0 && (
|
|
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
|
)}
|
|
</div>
|
|
|
|
{(totalSubmissions ?? 0) > PAGE_SIZE && (
|
|
<nav className="admin-pagination" aria-label="Paginación de respuestas">
|
|
{page > 1 ? (
|
|
<a href={pageHref(surveyId, page - 1)} className="admin-pagination-link">← Anterior</a>
|
|
) : (
|
|
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">← Anterior</span>
|
|
)}
|
|
<span className="admin-pagination-current">Página {page} de {totalPages}</span>
|
|
{page < totalPages ? (
|
|
<a href={pageHref(surveyId, page + 1)} className="admin-pagination-link">Siguiente →</a>
|
|
) : (
|
|
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">Siguiente →</span>
|
|
)}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|