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
|
- **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.
|
||||||
|
|||||||
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 { 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 [
|
||||||
|
{ data: surveysData, error: surveysError },
|
||||||
|
{ data: questionsData, error: questionsError },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('surveys').select('id, title').order('title'),
|
||||||
|
supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select('id, code, prompt, type, position')
|
.select('id, code, prompt, type, position')
|
||||||
.eq('is_sensitive', false)
|
.eq('is_sensitive', false)
|
||||||
.neq('type', 'text_long')
|
.neq('type', 'text_long')
|
||||||
.order('position', { ascending: true })
|
.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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
<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">
|
<span className="admin-toggle-icon" aria-hidden="true">
|
||||||
{isExpanded ? '▼' : '▶'}
|
{isExpanded ? '▼' : '▶'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
{row.submissionNum}
|
{row.submissionNum}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
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-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);
|
||||||
|
|||||||
Reference in New Issue
Block a user