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

@@ -111,3 +111,61 @@
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por - **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
promoverla de una vez en lugar de cada RPC reimplementando el JOIN. promoverla de una vez en lugar de cada RPC reimplementando el JOIN.
---
## [TECH-DEBT-011] Sección de pregunta inferida por posición, no modelada como dato
- **Estado:** ABIERTO — decisión consciente, no un descuido. Surgió en
ETAPA_ADMIN (vista detallada `/admin/responses/[id]`, 27 jun 2026): el prompt
de etapa asumía columnas `questions.section_number`/`section_title` que NO
existen. Verificado contra el esquema real (`supabase/migrations/
20260531000001_create_tables.sql` — única definición de `questions`,
`20260620000002_form_schema_v3.sql` solo agrega comentarios, ninguna
migración agrega esas columnas).
- **Descripción:** hoy `questions` no tiene ningún campo que declare "esta
pregunta pertenece a la sección X". La sección es una fila más en la misma
tabla, con `type='section'`, intercalada por `position` entre las preguntas
reales — el código (`lib/form-engine.ts:146-148` en el wizard, y la vista
detallada de admin) infiere el agrupamiento recorriendo las filas en orden
de `position` y "abriendo" una sección nueva cada vez que encuentra una fila
`type='section'`. El título de la sección es el `prompt` de esa fila — no
hay un campo `title` separado.
- **Por qué es deuda, no solo una curiosidad de modelado:** el motor está
pensado para ser un "motor de formularios de encuestas" genérico — no toda
encuesta futura va a tener secciones, y las que las tengan no necesariamente
van a querer modelarlas como filas-marcador en la misma tabla que las
preguntas. Hoy CUALQUIER consumidor que necesite agrupar por sección
(wizard, admin, y a futuro cualquier export/reporte) tiene que reimplementar
el mismo recorrido secuencial con el mismo supuesto implícito ("la fila
`type='section'` más cercana hacia atrás en `position` es la sección de esta
pregunta"). Si esa lógica se necesita en un tercer lugar, ya es momento de
promoverla a una función compartida — y si el motor crece a encuestas con
estructuras más ricas (subsecciones, secciones condicionales, secciones que
no son "preguntas" en absoluto), el modelo actual no escala sin reescribir
cada consumidor.
- **Alternativa más genérica y escalable (no implementada, propuesta para
evaluar cuando se priorice):** sacar la sección de `questions.type` y
modelarla como entidad propia — opción simple: `questions.section_id uuid
NULL REFERENCES sections(id)` + tabla `sections(id, survey_id, title,
position)`; opción más liviana sin tabla nueva: `questions.section_title
text NULL` + `questions.section_position int NULL` poblados directamente en
cada pregunta (denormalizado, sin fila-marcador `type='section'` separada).
Cualquiera de las dos deja "pertenece a la sección X" como un hecho
consultable con una columna, no como un cálculo derivado de recorrer
`position` — y ninguna asume que toda encuesta tiene secciones (el campo es
nullable). Migrar implicaría: (1) nueva(s) columna(s)/tabla, (2) backfill
desde las filas `type='section'` existentes, (3) actualizar
`lib/form-engine.ts` y la vista detallada de admin para leer el dato en vez
de inferirlo, (4) decidir si `type='section'` se deprecia o convive.
- **Acción tomada mientras tanto:** la vista detallada de admin
(`app/admin/responses/[id]/page.tsx`) replica la misma lógica de
`lib/form-engine.ts` (recorrido secuencial por `position`, abre sección en
cada `type='section'`) — no inventa columnas nuevas, no diverge del modelo
actual.
- **Trigger:** si una tercera feature necesita el mismo agrupamiento, extraer
la lógica a un helper compartido en `lib/` en vez de triplicarla. Si el
producto efectivamente evoluciona a soportar surveys con estructuras de
sección más complejas que el caso actual, evaluar la migración a columna/
tabla explícita antes de seguir apilando consumidores sobre el supuesto
implícito.

View 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>
)
}

View File

@@ -0,0 +1,27 @@
// Compartido entre la lista (page.tsx) y la vista detallada ([id]/page.tsx) —
// deben renderizar fecha y valor exactamente igual; antes vivían duplicados
// en los dos archivos y ya habían divergido.
export 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())}`
}
// null/undefined → null (renderizado como badge "sin dato"/"sin respuesta" por el caller)
export 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)
}
}

View File

@@ -1,20 +1,15 @@
import { createServerClient } from '@supabase/ssr' import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { ResponsesTable, type DisplayRow } from './responses-table' import { ResponsesTable, type DisplayRow } from './responses-table'
import { SurveySelect } from './survey-select'
import { SignOutButton } from '../sign-out-button' import { SignOutButton } from '../sign-out-button'
import { formatDate, renderValue } from './admin-format'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500 const PAGE_SIZE = 50
const PROMPT_MAX_LEN = 60 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 { interface AllowedQuestion {
id: string id: string
code: string code: string
@@ -36,6 +31,11 @@ interface ResponseRow {
answers: AnswerRow[] answers: AnswerRow[]
} }
interface SurveyOption {
id: string
title: string
}
async function getSupabaseSession() { async function getSupabaseSession() {
const cookieStore = await cookies() const cookieStore = await cookies()
return createServerClient( 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 { function truncate(text: string, max = PROMPT_MAX_LEN): string {
return text.length > max ? `${text.slice(0, max)}...` : text return text.length > max ? `${text.slice(0, max)}...` : text
} }
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato") function pageHref(surveyId: string | undefined, page: number): string {
function renderValue(value: unknown, type: string): string | null { const params = new URLSearchParams()
if (value === null || value === undefined) return null if (surveyId) params.set('survey_id', surveyId)
params.set('page', String(page))
switch (type) { return `/admin/responses?${params.toString()}`
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)
}
} }
export default async function AdminResponsesPage({ export default async function AdminResponsesPage({
searchParams, 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() 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Á, // 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan. // antes de tocar la tabla answers. Sus valores nunca se consultan.
const { data: questionsData, error: questionsError } = await supabase const [
.from('questions') { data: surveysData, error: surveysError },
.select('id, code, prompt, type, position') { data: questionsData, error: questionsError },
.eq('is_sensitive', false) ] = await Promise.all([
.neq('type', 'text_long') supabase.from('surveys').select('id, title').order('title'),
.order('position', { ascending: true }) 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) { if (questionsError) {
return ( return (
@@ -121,6 +119,7 @@ export default async function AdminResponsesPage({
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY // 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
// embebido a solo question_id permitidos (consulta, no post-filtro). // 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 let responsesQuery = supabase
.from('responses') .from('responses')
.select( .select(
@@ -138,10 +137,44 @@ export default async function AdminResponsesPage({
responsesQuery = responsesQuery.eq('survey_id', surveyId) 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 allowedIds.length > 0
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS) ? await Promise.all([
: { data: [] as ResponseRow[], error: null } 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) { if (responsesError) {
return ( return (
@@ -154,30 +187,7 @@ export default async function AdminResponsesPage({
} }
const responses = (responsesData ?? []) as unknown as ResponseRow[] const responses = (responsesData ?? []) as unknown as ResponseRow[]
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
// 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 }
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission // 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
const rows: DisplayRow[] = [] const rows: DisplayRow[] = []
@@ -194,7 +204,8 @@ export default async function AdminResponsesPage({
const isFirstInGroup = ai === 0 const isFirstInGroup = ai === 0
rows.push({ rows.push({
key: `${response.id}-${a.question_id}`, key: `${response.id}-${a.question_id}`,
submissionNum: idx + 1, responseId: response.id,
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
isFirstInGroup, isFirstInGroup,
date: isFirstInGroup ? formatDate(response.submitted_at) : '', date: isFirstInGroup ? formatDate(response.submitted_at) : '',
code: q.code, code: q.code,
@@ -212,25 +223,21 @@ export default async function AdminResponsesPage({
<div> <div>
<h1 className="admin-title">Control de respuestas</h1> <h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle"> <p className="admin-subtitle">
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales · {totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
actualizado {formatDate(new Date().toISOString())} {selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
{formatDate(new Date().toISOString())}
</p> </p>
<nav className="admin-survey-nav" aria-label="Selección de empresa"> <p className="admin-subtitle">
{SURVEY_OPTIONS.map((opt) => ( Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
<a </p>
key={opt.label} <div className="admin-survey-nav">
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'} <SurveySelect surveys={surveys} value={surveyId ?? ''} />
className={ </div>
opt.id === (surveyId ?? null) {surveysError && (
? 'admin-survey-link admin-survey-link-active' <p className="admin-error" role="alert">
: 'admin-survey-link' No se pudo cargar la lista de surveys para el filtro se muestra "Todos".
} </p>
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined} )}
>
{opt.label}
</a>
))}
</nav>
</div> </div>
<div className="admin-header-actions"> <div className="admin-header-actions">
<a href="/admin/responses" className="admin-refresh">Actualizar</a> <a href="/admin/responses" className="admin-refresh">Actualizar</a>
@@ -239,11 +246,27 @@ export default async function AdminResponsesPage({
</header> </header>
<div className="admin-table-wrapper"> <div className="admin-table-wrapper">
<ResponsesTable rows={rows} /> <ResponsesTable rows={rows} surveyId={surveyId} page={page} />
{rows.length === 0 && ( {rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p> <p className="admin-empty">Sin respuestas registradas todavía.</p>
)} )}
</div> </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> </div>
) )
} }

View File

@@ -1,9 +1,11 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link'
export interface DisplayRow { export interface DisplayRow {
key: string key: string
responseId: string
submissionNum: number submissionNum: number
isFirstInGroup: boolean isFirstInGroup: boolean
date: string date: string
@@ -14,7 +16,15 @@ export interface DisplayRow {
sector: string sector: string
} }
export function ResponsesTable({ rows }: { rows: DisplayRow[] }) { export function ResponsesTable({
rows,
surveyId,
page,
}: {
rows: DisplayRow[]
surveyId?: string
page: number
}) {
const [expanded, setExpanded] = useState<Set<number>>(new Set()) const [expanded, setExpanded] = useState<Set<number>>(new Set())
function toggle(submissionNum: number) { function toggle(submissionNum: number) {
@@ -26,6 +36,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
}) })
} }
function detailHref(responseId: string): string {
const params = new URLSearchParams()
if (surveyId) params.set('survey_id', surveyId)
params.set('page', String(page))
return `/admin/responses/${responseId}?${params.toString()}`
}
return ( return (
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
@@ -37,6 +54,7 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
<th>Valor</th> <th>Valor</th>
<th className="admin-col-optional">Departamento</th> <th className="admin-col-optional">Departamento</th>
<th className="admin-col-optional">Sector</th> <th className="admin-col-optional">Sector</th>
<th>Ver</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -54,28 +72,32 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
<tr <tr
key={row.key} key={row.key}
className={trClass} className={trClass}
// Conveniencia para mouse — sin semántica ARIA en la fila (evita
// anidar un control interactivo real, el Link "Ver", dentro de un
// elemento expuesto como role="button" a lectores de pantalla).
// El toggle accesible por teclado vive en el <button> de abajo.
onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined} onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
onKeyDown={
row.isFirstInGroup
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle(row.submissionNum)
}
}
: undefined
}
role={row.isFirstInGroup ? 'button' : undefined}
tabIndex={row.isFirstInGroup ? 0 : undefined}
aria-expanded={row.isFirstInGroup ? isExpanded : undefined}
> >
<td> <td>
{row.isFirstInGroup && ( {row.isFirstInGroup ? (
<span className="admin-toggle-icon" aria-hidden="true"> <button
{isExpanded ? '▼' : '▶'} type="button"
</span> className="admin-row-toggle-btn"
onClick={(e) => {
e.stopPropagation()
toggle(row.submissionNum)
}}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Colapsar respuesta' : 'Expandir respuesta'}
>
<span className="admin-toggle-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'}
</span>
{row.submissionNum}
</button>
) : (
row.submissionNum
)} )}
{row.submissionNum}
</td> </td>
<td>{row.date}</td> <td>{row.date}</td>
<td>{row.code}</td> <td>{row.code}</td>
@@ -89,6 +111,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
</td> </td>
<td className="admin-col-optional">{row.zone}</td> <td className="admin-col-optional">{row.zone}</td>
<td className="admin-col-optional">{row.sector}</td> <td className="admin-col-optional">{row.sector}</td>
<td onClick={(e) => e.stopPropagation()}>
{row.isFirstInGroup && (
<Link href={detailHref(row.responseId)} className="admin-action-link">
Ver
</Link>
)}
</td>
</tr> </tr>
) )
})} })}

View File

@@ -0,0 +1,31 @@
'use client'
import { useRouter } from 'next/navigation'
interface SurveyOption {
id: string
title: string
}
export function SurveySelect({ surveys, value }: { surveys: SurveyOption[]; value: string }) {
const router = useRouter()
return (
<select
className="admin-survey-select"
aria-label="Selección de encuesta"
value={value}
onChange={(e) => {
const id = e.target.value
router.push(id ? `/admin/responses?survey_id=${id}` : '/admin/responses')
}}
>
<option value="">Todos los surveys</option>
{surveys.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
)
}

View File

@@ -1007,19 +1007,46 @@ body {
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; } .admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; }
.admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; } .admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } .admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.admin-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid var(--color-border); color: var(--color-text-muted); text-decoration: none;
transition: all 0.2s ease;
}
.admin-survey-link:hover { color: var(--color-text); border-color: var(--color-primary); }
.admin-survey-link-active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.admin-refresh { .admin-refresh {
display: inline-block; padding: 8px 16px; border-radius: var(--radius); display: inline-block; padding: 8px 16px; border-radius: var(--radius);
background: var(--color-primary); color: #fff; font-size: 0.8125rem; background: var(--color-primary); color: #fff; font-size: 0.8125rem;
font-weight: 600; text-decoration: none; white-space: nowrap; font-weight: 600; text-decoration: none; white-space: nowrap;
} }
.admin-refresh:hover { background: var(--color-primary-hover); } .admin-refresh:hover { background: var(--color-primary-hover); }
.admin-survey-select {
font-size: 0.8125rem; padding: 6px 10px; border-radius: var(--radius);
border: 1px solid var(--color-border); color: var(--color-text);
background: var(--color-surface);
}
.admin-action-link {
font-size: 0.75rem; font-weight: 600; color: var(--color-primary);
text-decoration: none;
}
.admin-action-link:hover { text-decoration: underline; }
.admin-pagination {
display: flex; align-items: center; justify-content: center; gap: 16px;
margin-top: 16px; font-size: 0.8125rem;
}
.admin-pagination-link { color: var(--color-primary); text-decoration: none; font-weight: 600; }
.admin-pagination-link:hover { text-decoration: underline; }
.admin-pagination-disabled { color: var(--color-text-muted); cursor: default; }
.admin-pagination-current { color: var(--color-text-muted); }
.admin-back-link {
display: inline-block; margin-bottom: 12px; font-size: 0.8125rem;
font-weight: 600; color: var(--color-primary); text-decoration: none;
}
.admin-back-link:hover { text-decoration: underline; }
.admin-detail-section { margin-bottom: 24px; }
.admin-detail-section-title {
font-size: 1.0625rem; font-weight: 700; color: var(--color-text);
margin: 0 0 10px;
}
.admin-detail-instance { margin-bottom: 14px; }
.admin-instance-badge {
display: inline-block; font-size: 0.75rem; font-weight: 600;
color: var(--color-primary-hover); background: var(--color-primary-light);
padding: 3px 10px; border-radius: 999px; margin-bottom: 6px;
}
.admin-table-wrapper { .admin-table-wrapper {
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border); overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card); border-radius: var(--radius); box-shadow: var(--shadow-card);
@@ -1040,6 +1067,10 @@ body {
display: inline-block; width: 14px; margin-right: 4px; display: inline-block; width: 14px; margin-right: 4px;
color: var(--color-text-muted); font-size: 0.7rem; color: var(--color-text-muted); font-size: 0.7rem;
} }
.admin-row-toggle-btn {
background: none; border: none; padding: 0; margin: 0;
font: inherit; color: inherit; cursor: pointer;
}
.admin-nodata { .admin-nodata {
display: inline-block; padding: 1px 8px; border-radius: 10px; display: inline-block; padding: 1px 8px; border-radius: 10px;
background: var(--color-re-light); color: var(--color-re-dark); background: var(--color-re-light); color: var(--color-re-dark);