Con el listado de encuestas cargadas/respondidas

This commit is contained in:
markosbenitez
2026-06-10 08:59:50 -03:00
parent 2f39ce555c
commit bf963e8894
6 changed files with 1119 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
import { createClient } from '@supabase/supabase-js'
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V).
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
export const dynamic = 'force-dynamic'
const MAX_ROWS = 200
interface ResponseRow {
id: string
submitted_at: string
qi_zone: string | null
qi_sector: string | null
qi_age_bucket: string | null
ip_hash: string | null
surveys: { title: string } | { title: string }[] | null
answers: { count: number }[]
}
function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
return createClient(url, serviceKey, { auth: { persistSession: false } })
}
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 dash(value: string | null): string {
return value ?? '—'
}
export default async function AdminResponsesPage() {
const supabase = getSupabaseAdmin()
const { data, error } = await supabase
.from('responses')
.select(
`
id,
submitted_at,
qi_zone,
qi_sector,
qi_age_bucket,
ip_hash,
surveys ( title ),
answers ( count )
`
)
.order('submitted_at', { ascending: false })
.limit(MAX_ROWS)
if (error) {
return (
<div className="admin-page" role="main">
<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">{error.message}</pre>
</div>
)
}
const { count: totalCount } = await supabase
.from('responses')
.select('*', { count: 'exact', head: true })
const rows = (data ?? []) as unknown as ResponseRow[]
return (
<div className="admin-page" role="main">
<header className="admin-header">
<div>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle">
{totalCount ?? 0} respuestas en total · últimas {rows.length} ·
actualizado {formatDate(new Date().toISOString())}
</p>
</div>
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</header>
<div className="admin-table-wrapper">
<table className="admin-table">
<thead>
<tr>
<th>#</th>
<th>Fecha</th>
<th>Encuesta</th>
<th>Departamento</th>
<th className="admin-col-optional">Sector</th>
<th className="admin-col-optional">Rango etario</th>
<th>Respuestas</th>
<th className="admin-col-optional">IP hash</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const survey = Array.isArray(row.surveys) ? row.surveys[0] : row.surveys
const answerCount = row.answers?.[0]?.count ?? 0
return (
<tr key={row.id}>
<td>{i + 1}</td>
<td>{formatDate(row.submitted_at)}</td>
<td>{survey?.title ?? '—'}</td>
<td>{dash(row.qi_zone)}</td>
<td className="admin-col-optional">{dash(row.qi_sector)}</td>
<td className="admin-col-optional">{dash(row.qi_age_bucket)}</td>
<td>{answerCount}</td>
<td className="admin-col-optional admin-iphash">
{row.ip_hash ? `${row.ip_hash.slice(0, 8)}...` : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
{rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p>
)}
</div>
</div>
)
}

View File

@@ -742,11 +742,48 @@ body {
}
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
/* ── Admin: control de respuestas (Etapa 3V) ── */
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.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-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-table-wrapper {
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card);
}
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.admin-table th {
position: sticky; top: 0; background: var(--color-re-dark); color: #fff;
font-weight: 600; text-align: left; padding: 10px 12px; white-space: nowrap;
}
.admin-table td { padding: 10px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); }
.admin-table tbody tr:nth-child(even) { background: var(--color-bg); }
.admin-table tbody tr:hover { background: var(--color-primary-light); }
.admin-table tbody tr:last-child td { border-bottom: none; }
.admin-iphash { font-family: 'Courier New', monospace; color: var(--color-text-muted); }
.admin-empty { padding: 24px; text-align: center; color: var(--color-text-muted); }
.admin-error { color: var(--color-error); font-weight: 600; }
.admin-error-detail {
background: var(--color-error-bg); border-radius: var(--radius);
padding: 12px; font-family: 'Courier New', monospace; font-size: 0.75rem;
overflow-x: auto;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.db-kpi-row { grid-template-columns: repeat(2,1fr); }
.db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-page { flex-direction: column; }
.admin-col-optional { display: none; }
}
/* ── Reduced motion ── */