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:
58
TECH_DEBT.md
58
TECH_DEBT.md
@@ -111,3 +111,61 @@
|
||||
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
|
||||
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
|
||||
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.
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
27
app/admin/responses/admin-format.ts
Normal file
27
app/admin/responses/admin-format.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface DisplayRow {
|
||||
key: string
|
||||
responseId: string
|
||||
submissionNum: number
|
||||
isFirstInGroup: boolean
|
||||
date: string
|
||||
@@ -14,7 +16,15 @@ export interface DisplayRow {
|
||||
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())
|
||||
|
||||
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 (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
@@ -37,6 +54,7 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
<th>Valor</th>
|
||||
<th className="admin-col-optional">Departamento</th>
|
||||
<th className="admin-col-optional">Sector</th>
|
||||
<th>Ver</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -54,28 +72,32 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
<tr
|
||||
key={row.key}
|
||||
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}
|
||||
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>
|
||||
{row.isFirstInGroup && (
|
||||
<span className="admin-toggle-icon" aria-hidden="true">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
{row.isFirstInGroup ? (
|
||||
<button
|
||||
type="button"
|
||||
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>{row.date}</td>
|
||||
<td>{row.code}</td>
|
||||
@@ -89,6 +111,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
</td>
|
||||
<td className="admin-col-optional">{row.zone}</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>
|
||||
)
|
||||
})}
|
||||
|
||||
31
app/admin/responses/survey-select.tsx
Normal file
31
app/admin/responses/survey-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1007,19 +1007,46 @@ body {
|
||||
.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-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 {
|
||||
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
|
||||
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
|
||||
font-weight: 600; text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.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 {
|
||||
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-card);
|
||||
@@ -1040,6 +1067,10 @@ body {
|
||||
display: inline-block; width: 14px; margin-right: 4px;
|
||||
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 {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||
background: var(--color-re-light); color: var(--color-re-dark);
|
||||
|
||||
Reference in New Issue
Block a user