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:
274
app/admin/responses/[id]/page.tsx
Normal file
274
app/admin/responses/[id]/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { formatDate, renderValue } from '../admin-format'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface QuestionRow {
|
||||
id: string
|
||||
code: string
|
||||
prompt: string
|
||||
type: string
|
||||
position: number
|
||||
instance_of: string | null
|
||||
}
|
||||
|
||||
interface AnswerRow {
|
||||
question_id: string
|
||||
value: unknown
|
||||
instance_id: string
|
||||
}
|
||||
|
||||
interface ResponseMeta {
|
||||
id: string
|
||||
submitted_at: string
|
||||
qi_zone: string | null
|
||||
qi_sector: string | null
|
||||
qi_age_bucket: string | null
|
||||
survey_id: string
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
code: string
|
||||
prompt: string
|
||||
rendered: string | null
|
||||
}
|
||||
|
||||
interface InstanceGroup {
|
||||
instanceId: string
|
||||
rows: DetailRow[]
|
||||
}
|
||||
|
||||
interface SectionGroup {
|
||||
title: string | null
|
||||
instances: InstanceGroup[]
|
||||
}
|
||||
|
||||
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 backHref(surveyId: string | undefined, page: string | undefined): string {
|
||||
const params = new URLSearchParams()
|
||||
if (surveyId) params.set('survey_id', surveyId)
|
||||
if (page) params.set('page', page)
|
||||
const qs = params.toString()
|
||||
return `/admin/responses${qs ? `?${qs}` : ''}`
|
||||
}
|
||||
|
||||
export default async function ResponseDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { survey_id: backSurveyId, page: backPage } = await searchParams
|
||||
const back = backHref(backSurveyId, backPage)
|
||||
const supabase = await getSupabaseSession()
|
||||
|
||||
const { data: response, error: responseError } = await supabase
|
||||
.from('responses')
|
||||
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (responseError || !response) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
<h1 className="admin-title">Respuesta no encontrada</h1>
|
||||
<p className="admin-empty">
|
||||
No existe esta respuesta o no tenés permiso para verla.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = response as ResponseMeta
|
||||
|
||||
// Mismo filtro de sensibles que la lista (regla no negociable #4 CLAUDE.md) — NO diverge.
|
||||
const { data: questionsData, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('id, code, prompt, type, position, instance_of')
|
||||
.eq('survey_id', meta.survey_id)
|
||||
.eq('is_sensitive', false)
|
||||
.neq('type', 'text_long')
|
||||
.order('position', { ascending: true })
|
||||
|
||||
if (questionsError) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
<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 questions = (questionsData ?? []) as QuestionRow[]
|
||||
const answerQuestionIds = questions.filter((q) => q.type !== 'section').map((q) => q.id)
|
||||
|
||||
const { data: answersData, error: answersError } =
|
||||
answerQuestionIds.length > 0
|
||||
? await supabase
|
||||
.from('answers')
|
||||
.select('question_id, value, instance_id')
|
||||
.eq('response_id', id)
|
||||
.in('question_id', answerQuestionIds)
|
||||
: { data: [] as AnswerRow[], error: null }
|
||||
|
||||
if (answersError) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
<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">{answersError.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const answers = (answersData ?? []) as AnswerRow[]
|
||||
const answersByQuestion = new Map<string, AnswerRow[]>()
|
||||
answers.forEach((a) => {
|
||||
const list = answersByQuestion.get(a.question_id) ?? []
|
||||
list.push(a)
|
||||
answersByQuestion.set(a.question_id, list)
|
||||
})
|
||||
|
||||
// Agrupar por sección. No existe section_number/section_title en `questions`
|
||||
// (verificado — ver TECH_DEBT.md). Las secciones hoy son filas type='section'
|
||||
// intercaladas por position, mismo modelo que usa el wizard en
|
||||
// lib/form-engine.ts:146-148 — se replica esa lógica acá, no se inventa una
|
||||
// columna nueva.
|
||||
const sections: SectionGroup[] = []
|
||||
let current: SectionGroup = { title: null, instances: [] }
|
||||
let hasContent = false
|
||||
|
||||
function instanceGroupFor(section: SectionGroup, instanceId: string): InstanceGroup {
|
||||
let group = section.instances.find((g) => g.instanceId === instanceId)
|
||||
if (!group) {
|
||||
group = { instanceId, rows: [] }
|
||||
section.instances.push(group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
questions.forEach((q) => {
|
||||
if (q.type === 'section') {
|
||||
if (hasContent || current.title !== null) sections.push(current)
|
||||
current = { title: q.prompt, instances: [] }
|
||||
hasContent = false
|
||||
return
|
||||
}
|
||||
|
||||
const qAnswers = answersByQuestion.get(q.id)
|
||||
if (qAnswers && qAnswers.length > 0) {
|
||||
qAnswers.forEach((a) => {
|
||||
const group = instanceGroupFor(current, a.instance_id)
|
||||
group.rows.push({ code: q.code, prompt: q.prompt, rendered: renderValue(a.value, q.type) })
|
||||
})
|
||||
hasContent = true
|
||||
} else if (!q.instance_of) {
|
||||
// Pregunta normal sin respuesta — placeholder "sin respuesta" visible.
|
||||
// Si es instance_of y no hay respuestas, no se generaron instancias
|
||||
// (igual que el wizard no muestra pantallas de instancia sin generador
|
||||
// disparado) — no se fabrica un grupo "Familiar" vacío.
|
||||
const group = instanceGroupFor(current, 'default')
|
||||
group.rows.push({ code: q.code, prompt: q.prompt, rendered: null })
|
||||
hasContent = true
|
||||
}
|
||||
})
|
||||
if (hasContent || current.title !== null) sections.push(current)
|
||||
|
||||
sections.forEach((s) => {
|
||||
s.instances.sort((a, b) => {
|
||||
if (a.instanceId === 'default') return -1
|
||||
if (b.instanceId === 'default') return 1
|
||||
return a.instanceId.localeCompare(b.instanceId, undefined, { numeric: true })
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<h1 className="admin-title">Respuesta {meta.id.slice(0, 8)}</h1>
|
||||
<p className="admin-subtitle">
|
||||
{formatDate(meta.submitted_at)} · Departamento: {meta.qi_zone ?? '—'} · Sector:{' '}
|
||||
{meta.qi_sector ?? '—'} · Edad: {meta.qi_age_bucket ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sections.map((section, si) => (
|
||||
<section key={si} className="admin-detail-section">
|
||||
{section.title && <h2 className="admin-detail-section-title">{section.title}</h2>}
|
||||
{section.instances.map((group) => (
|
||||
<div key={group.instanceId} className="admin-detail-instance">
|
||||
{group.instanceId !== 'default' && (
|
||||
<span className="admin-instance-badge">
|
||||
Familiar {group.instanceId.replace('familiar_', '')}
|
||||
</span>
|
||||
)}
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Pregunta</th>
|
||||
<th>Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
<td>{row.code}</td>
|
||||
<td className="admin-prompt">{row.prompt}</td>
|
||||
<td>
|
||||
{row.rendered === null ? (
|
||||
<span className="admin-nodata">sin respuesta</span>
|
||||
) : (
|
||||
row.rendered
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="admin-empty">Esta respuesta no tiene preguntas visibles para mostrar.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user