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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user