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 (
← Volver a la lista

Respuesta no encontrada

No existe esta respuesta o no tenés permiso para verla.

) } 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 (
← Volver a la lista

Control de respuestas

No fue posible conectarse a la base de datos.

{questionsError.message}
) } 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 (
← Volver a la lista

Control de respuestas

No fue posible conectarse a la base de datos.

{answersError.message}
) } const answers = (answersData ?? []) as AnswerRow[] const answersByQuestion = new Map() 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 (
← Volver a la lista

Respuesta {meta.id.slice(0, 8)}

{formatDate(meta.submitted_at)} · Departamento: {meta.qi_zone ?? '—'} · Sector:{' '} {meta.qi_sector ?? '—'} · Edad: {meta.qi_age_bucket ?? '—'}

{sections.map((section, si) => (
{section.title &&

{section.title}

} {section.instances.map((group) => (
{group.instanceId !== 'default' && ( Familiar {group.instanceId.replace('familiar_', '')} )} {group.rows.map((row, ri) => ( ))}
Código Pregunta Valor
{row.code} {row.prompt} {row.rendered === null ? ( sin respuesta ) : ( row.rendered )}
))}
))} {sections.length === 0 && (

Esta respuesta no tiene preguntas visibles para mostrar.

)}
) }