Con el listado de encuestas cargadas/respondidas
This commit is contained in:
126
app/admin/responses/page.tsx
Normal file
126
app/admin/responses/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 ── */
|
||||
|
||||
Reference in New Issue
Block a user