diff --git a/TECH_DEBT.md b/TECH_DEBT.md index fbb3847..390d0d3 100644 --- a/TECH_DEBT.md +++ b/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. diff --git a/app/admin/responses/[id]/page.tsx b/app/admin/responses/[id]/page.tsx new file mode 100644 index 0000000..9ba4d38 --- /dev/null +++ b/app/admin/responses/[id]/page.tsx @@ -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 ( +
+ ← Volver a la lista +

Respuesta no encontrada

+

+ No existe esta respuesta o no tenés permiso para verla. +

+
+ ) + } + + 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 ( +
+ ← Volver a la lista +

Control de respuestas

+

No fue posible conectarse a la base de datos.

+
{questionsError.message}
+
+ ) + } + + 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 ( +
+ ← Volver a la lista +

Control de respuestas

+

No fue posible conectarse a la base de datos.

+
{answersError.message}
+
+ ) + } + + const answers = (answersData ?? []) as AnswerRow[] + const answersByQuestion = new Map() + 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 ( +
+ ← Volver a la lista + +
+
+

Respuesta {meta.id.slice(0, 8)}

+

+ {formatDate(meta.submitted_at)} · Departamento: {meta.qi_zone ?? '—'} · Sector:{' '} + {meta.qi_sector ?? '—'} · Edad: {meta.qi_age_bucket ?? '—'} +

+
+
+ + {sections.map((section, si) => ( +
+ {section.title &&

{section.title}

} + {section.instances.map((group) => ( +
+ {group.instanceId !== 'default' && ( + + Familiar {group.instanceId.replace('familiar_', '')} + + )} + + + + + + + + + + {group.rows.map((row, ri) => ( + + + + + + ))} + +
CódigoPreguntaValor
{row.code}{row.prompt} + {row.rendered === null ? ( + sin respuesta + ) : ( + row.rendered + )} +
+
+ ))} +
+ ))} + + {sections.length === 0 && ( +

Esta respuesta no tiene preguntas visibles para mostrar.

+ )} +
+ ) +} diff --git a/app/admin/responses/admin-format.ts b/app/admin/responses/admin-format.ts new file mode 100644 index 0000000..8b55901 --- /dev/null +++ b/app/admin/responses/admin-format.ts @@ -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) + } +} diff --git a/app/admin/responses/page.tsx b/app/admin/responses/page.tsx index 1f9c3a8..6dd9ac9 100644 --- a/app/admin/responses/page.tsx +++ b/app/admin/responses/page.tsx @@ -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({

Control de respuestas

- {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ágina {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales +

+
+ +
+ {surveysError && ( +

+ No se pudo cargar la lista de surveys para el filtro — se muestra "Todos". +

+ )}
Actualizar @@ -239,11 +246,27 @@ export default async function AdminResponsesPage({
- + {rows.length === 0 && (

Sin respuestas registradas todavía.

)}
+ + {(totalSubmissions ?? 0) > PAGE_SIZE && ( + + )}
) } diff --git a/app/admin/responses/responses-table.tsx b/app/admin/responses/responses-table.tsx index 2bed8cf..a2529bd 100644 --- a/app/admin/responses/responses-table.tsx +++ b/app/admin/responses/responses-table.tsx @@ -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>(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 ( @@ -37,6 +54,7 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) { + @@ -54,28 +72,32 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) { 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} > @@ -89,6 +111,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) { + ) })} diff --git a/app/admin/responses/survey-select.tsx b/app/admin/responses/survey-select.tsx new file mode 100644 index 0000000..99d7b7c --- /dev/null +++ b/app/admin/responses/survey-select.tsx @@ -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 ( + + ) +} diff --git a/app/globals.css b/app/globals.css index adeed67..54eb54b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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);
Valor Departamento SectorVer
- {row.isFirstInGroup && ( - + {row.isFirstInGroup ? ( + + ) : ( + row.submissionNum )} - {row.submissionNum} {row.date} {row.code} {row.zone} {row.sector} e.stopPropagation()}> + {row.isFirstInGroup && ( + + Ver + + )} +