feat(admin): vista respuestas mejorada + vista detallada [admin]

/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).
This commit is contained in:
markosbenitez
2026-06-27 16:48:32 -03:00
parent a660928085
commit a0253b4f90
7 changed files with 584 additions and 111 deletions

View File

@@ -1,20 +1,15 @@
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 MAX_SUBMISSIONS = 500
const PAGE_SIZE = 50
const PROMPT_MAX_LEN = 60
const SURVEY_OPTIONS = [
{ label: 'Todos', id: null },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
] as const
interface AllowedQuestion {
id: string
code: string
@@ -36,6 +31,11 @@ interface ResponseRow {
answers: AnswerRow[]
}
interface SurveyOption {
id: string
title: string
}
async function getSupabaseSession() {
const cookieStore = await cookies()
return createServerClient(
@@ -60,50 +60,48 @@ async function getSupabaseSession() {
)
}
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)
}
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 }>
searchParams: Promise<{ survey_id?: string; page?: string }>
}) {
const { survey_id: surveyId } = await searchParams
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: 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 })
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 (
@@ -121,6 +119,7 @@ export default async function AdminResponsesPage({
// 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(
@@ -138,10 +137,44 @@ export default async function AdminResponsesPage({
responsesQuery = responsesQuery.eq('survey_id', surveyId)
}
const { data: responsesData, error: responsesError } =
// 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 responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS)
: { data: [] as ResponseRow[], error: null }
? 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 (
@@ -154,30 +187,7 @@ export default async function AdminResponsesPage({
}
const responses = (responsesData ?? []) as unknown as ResponseRow[]
// 3. Contadores totales (independientes del límite de 500 submissions),
// filtrados por survey_id cuando hay filtro activo.
let submissionsCountQuery = supabase
.from('responses')
.select('*', { count: 'exact', head: true })
if (surveyId) {
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
}
const { count: totalSubmissions } = await submissionsCountQuery
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)
}
const { count: totalAnswers } =
allowedIds.length > 0 ? await answersCountQuery : { count: 0 }
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[] = []
@@ -194,7 +204,8 @@ export default async function AdminResponsesPage({
const isFirstInGroup = ai === 0
rows.push({
key: `${response.id}-${a.question_id}`,
submissionNum: idx + 1,
responseId: response.id,
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
isFirstInGroup,
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
code: q.code,
@@ -212,25 +223,21 @@ export default async function AdminResponsesPage({
<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())}
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
{formatDate(new Date().toISOString())}
</p>
<nav className="admin-survey-nav" aria-label="Selección de empresa">
{SURVEY_OPTIONS.map((opt) => (
<a
key={opt.label}
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'}
className={
opt.id === (surveyId ?? null)
? 'admin-survey-link admin-survey-link-active'
: 'admin-survey-link'
}
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
>
{opt.label}
</a>
))}
</nav>
<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>
@@ -239,11 +246,27 @@ export default async function AdminResponsesPage({
</header>
<div className="admin-table-wrapper">
<ResponsesTable rows={rows} />
<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>
)
}