Remove all database dependencies and simplify application
All checks were successful
build-and-deploy / build (push) Successful in 3m37s
build-and-deploy / deploy (push) Successful in 4s

BREAKING CHANGE: Remove all data analysis features

This commit removes all database-dependent functionality and simplifies
the application to focus on authentication and user management only.

Changes:
- Remove all /api/data and /api/metadata server endpoints
- Remove Supabase configuration from nuxt.config.ts and .env.example
- Remove @supabase/supabase-js dependency from package.json
- Delete data analysis pages: explorer, metadatos, rawExplorer, panorama,
  comparativa-cosechas, informe-ingresos
- Simplify sidebar navigation to show only "Inicio"
- Update home page to focus on authentication and profile management
- Remove "Supabase" and "Solo lectura" badges from navbar
- Keep only auth-related API endpoints: /api/auth/status and /api/auth/check-group

The application now serves as an authentication-protected portal with:
- Authentik SSO integration
- User profile management
- Settings and notifications pages (coming soon)
- No database or data analysis features
This commit is contained in:
2025-10-13 13:37:52 -06:00
parent 1baa4de990
commit 608b4dbe26
19 changed files with 46 additions and 3653 deletions

View File

@@ -1,6 +1,2 @@
# Supabase Configuration
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Authentik Configuration # Authentik Configuration
NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com NUXT_PUBLIC_AUTHENTIK_URL=https://authentik.nucleoriofrio.com

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

View File

@@ -232,42 +232,6 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
icon: 'i-lucide-home', icon: 'i-lucide-home',
to: '/', to: '/',
active: route.path === '/' active: route.path === '/'
},
{
label: 'Panorama Facturador',
icon: 'i-lucide-bar-chart-3',
to: '/panorama',
active: route.path === '/panorama'
},
{
label: 'Informe Ingresos',
icon: 'i-lucide-file-bar-chart',
to: '/informe-ingresos',
active: route.path === '/informe-ingresos'
},
{
label: 'Comparativa Cosechas',
icon: 'i-lucide-calendar-range',
to: '/comparativa-cosechas',
active: route.path === '/comparativa-cosechas'
},
{
label: 'Explorador de datos',
icon: 'i-lucide-table',
to: '/explorer',
active: route.path === '/explorer'
},
{
label: 'Metadatos',
icon: 'i-lucide-database',
to: '/metadatos',
active: route.path === '/metadatos'
},
{
label: 'Explorador de datos raw',
icon: 'i-lucide-table',
to: '/rawExplorer',
active: route.path === '/rawExplorer'
} }
]) ])

View File

@@ -14,8 +14,6 @@
<UDashboardSidebarToggle variant="subtle" /> <UDashboardSidebarToggle variant="subtle" />
</template> </template>
<template #trailing> <template #trailing>
<UBadge variant="subtle" label="Supabase" class="uppercase tracking-wide" />
<UBadge variant="subtle" label="Solo lectura" class="uppercase tracking-wide" />
<AuthSessionStatusButton /> <AuthSessionStatusButton />
<UserMenu /> <UserMenu />
</template> </template>

View File

@@ -1,530 +0,0 @@
<template>
<div class="flex flex-col">
<!-- Toolbar con selector de cosechas -->
<UDashboardToolbar>
<template #left>
<div class="flex items-center gap-4">
<span class="text-sm text-[var(--brand-text-muted)]">Seleccionar cosechas a comparar:</span>
</div>
</template>
<template #right>
<div class="flex items-center gap-3">
<UButton
@click="mostrarConfiguracion = !mostrarConfiguracion"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-palette"
:class="{ 'bg-[#c08040]/20 text-[#c08040]': mostrarConfiguracion }"
>
Configurar estilos
</UButton>
<div class="w-px h-4 bg-[var(--brand-border)]" />
<USwitch
v-model="pageSections.heatmap"
size="xs"
label="Heatmap"
/>
<USwitch
v-model="pageSections.totales"
size="xs"
label="Totales"
/>
<USwitch
v-model="pageSections.evolucion"
size="xs"
label="Evolución"
/>
</div>
</template>
</UDashboardToolbar>
<div class="flex flex-col gap-8 p-6">
<!-- Loading State -->
<UCard v-if="loading && !ingresosStore.hasData" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
<div class="flex items-center gap-3">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
</div>
</div>
</UCard>
<!-- Error State -->
<div v-else-if="error" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
<p>Error al cargar datos: {{ error }}</p>
</div>
<!-- Main Content -->
<template v-else>
<!-- Metadatos de Resumen de Ingresos -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-database" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Fuente de Datos: Resumen Diario de Ingresos</h3>
</div>
</template>
<MetadatosCard v-if="resumenIngresosMetadata" :metadata="resumenIngresosMetadata" :compact="true" />
</UCard>
<!-- Selector de Cosechas -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-calendar-range" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Cosechas a Comparar</h3>
</div>
</template>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<label
v-for="cosecha in cosechasDisponibles"
:key="cosecha.id"
class="flex items-center gap-2 p-3 rounded-lg border transition-all"
:class="[
cosecha.disabled
? 'border-gray-600/20 bg-gray-800/20 cursor-not-allowed opacity-50'
: cosechasSeleccionadas.includes(cosecha.id)
? 'border-[#c08040] bg-[#c08040]/10 cursor-pointer'
: 'border-[var(--brand-border)] hover:border-[#c08040]/50 cursor-pointer'
]"
>
<input
type="checkbox"
:value="cosecha.id"
v-model="cosechasSeleccionadas"
:disabled="cosecha.disabled"
class="rounded border-[var(--brand-border)] text-[#c08040] focus:ring-[#c08040] disabled:cursor-not-allowed"
/>
<div class="flex flex-col flex-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-[var(--brand-text)]">{{ cosecha.label }}</span>
<span
v-if="!cosecha.disabled"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-[#c08040]/20 text-[#c08040] font-semibold"
>
{{ cosecha.registros }}
</span>
</div>
<span class="text-xs text-[var(--brand-text-muted)]">
{{ cosecha.disabled ? 'Sin datos' : cosecha.periodo }}
</span>
</div>
</label>
</div>
</UCard>
<!-- Vista Heatmap -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.heatmap">
<ComparativaCosechasHeatmap :ingresos="resumenIngresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Resumen General por Cosecha -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.totales">
<ComparativaCosechasTotales :ingresos="resumenIngresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Evolución Temporal Comparada -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.evolucion">
<ComparativaCosechasEvolucion :ingresos="resumenIngresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Empty State -->
<UCard v-if="cosechasSeleccionadas.length === 0" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-16 text-[var(--brand-text-muted)]">
<UIcon name="i-lucide-bar-chart-2" class="size-16 opacity-50" />
<p class="text-sm">Selecciona al menos una cosecha para ver las comparativas</p>
</div>
</UCard>
</template>
</div>
<!-- Modal de Configuración de Estilos -->
<UCard class="brand-card" v-if="mostrarConfiguracion">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-palette" class="size-5 text-[#c08040]" />
<h3 id="modal-title" class="text-base font-semibold text-[var(--brand-text)]">Configuración de Estilos</h3>
</div>
<UButton
@click="resetearConfiguracion"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-rotate-ccw"
>
Resetear
</UButton>
</div>
<p id="modal-description" class="sr-only">
Personaliza los colores, dimensiones y opacidad de las gráficas
</p>
</template>
<div class="flex flex-col gap-6">
<!-- Sección: Colores de Cosechas -->
<div>
<h4 class="text-sm font-semibold text-[var(--brand-text)] mb-3 flex items-center gap-2">
<UIcon name="i-lucide-droplets" class="size-4" />
Colores de Cosechas (por año)
</h4>
<p class="text-xs text-[var(--brand-text-muted)] mb-3">
Los colores son fijos para cada cosecha, sin importar el orden en que se seleccionen
</p>
<div class="grid grid-cols-2 gap-3">
<div
v-for="(cosecha, index) in cosechasDefiniciones"
:key="`color-${cosecha.id}`"
class="flex items-center gap-3 p-3 rounded-lg border border-[var(--brand-border)] bg-[var(--brand-bg-secondary)]"
>
<label class="text-xs text-[var(--brand-text-muted)] flex-1">
{{ cosecha.label }}
</label>
<input
type="color"
v-model="estilosGraficas.coloresCosechas[index]"
class="w-10 h-8 rounded cursor-pointer border border-[var(--brand-border)]"
/>
</div>
</div>
</div>
<!-- Sección: Dimensiones -->
<div>
<h4 class="text-sm font-semibold text-[var(--brand-text)] mb-3 flex items-center gap-2">
<UIcon name="i-lucide-ruler" class="size-4" />
Dimensiones
</h4>
<div class="grid grid-cols-2 gap-4">
<!-- Ancho de celda (Heatmap) -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Ancho de celda (Heatmap)
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.anchoCelda"
min="40"
max="150"
step="10"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.anchoCelda }}px
</span>
</div>
</div>
<!-- Alto de celda (Heatmap) -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Alto de celda (Heatmap)
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.altoCelda"
min="4"
max="20"
step="1"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.altoCelda }}px
</span>
</div>
</div>
<!-- Ancho máximo de barra -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Ancho máximo de barra
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.anchoMaxBarra"
min="150"
max="500"
step="25"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.anchoMaxBarra }}px
</span>
</div>
</div>
<!-- Alto de barra -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Alto de barra
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.altoBarra"
min="6"
max="24"
step="2"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.altoBarra }}px
</span>
</div>
</div>
</div>
</div>
<!-- Sección: Opacidad -->
<div>
<h4 class="text-sm font-semibold text-[var(--brand-text)] mb-3 flex items-center gap-2">
<UIcon name="i-lucide-sun-dim" class="size-4" />
Opacidad
</h4>
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Opacidad de celdas vacías (Heatmap)
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.opacidadVacias"
min="0"
max="0.3"
step="0.05"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ (estilosGraficas.opacidadVacias * 100).toFixed(0) }}%
</span>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
@click="mostrarConfiguracion = false"
color="neutral"
variant="ghost"
>
Cerrar
</UButton>
</div>
</template>
</UCard>
</div>
</template>
<script setup lang="ts">
import { useTableDataStore } from '~/stores/tableDataFactory'
import { useMetadataStore } from '~/stores/metadata'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
definePageMeta({
layout: 'informe',
title: 'Comparativa Cosechas'
})
// Definir secciones específicas de esta página
const pageSections = ref({
heatmap: true,
totales: false,
evolucion: false
})
// Estado del modal de configuración
const mostrarConfiguracion = ref(false)
// Configuración de estilos por defecto
const estilosGraficasDefault = {
coloresCosechas: [
'#c08040', // Cosecha 20-21
'#d99a56', // Cosecha 21-22
'#8b6f47', // Cosecha 22-23
'#a0826e', // Cosecha 23-24
'#b89968', // Cosecha 24-25
'#f0c07c' // Cosecha 25-26
],
anchoCelda: 80,
altoCelda: 6,
anchoMaxBarra: 300,
altoBarra: 8,
opacidadVacias: 0.05
}
// Cargar configuración desde cookies
function cargarConfiguracionDesdeCookies() {
const cookie = useCookie<typeof estilosGraficasDefault>('estilos-graficas', {
maxAge: 60 * 60 * 24 * 365, // 1 año
sameSite: 'lax'
})
if (cookie.value) {
try {
return { ...estilosGraficasDefault, ...cookie.value }
} catch (e) {
console.error('Error al cargar configuración desde cookies:', e)
return { ...estilosGraficasDefault }
}
}
return { ...estilosGraficasDefault }
}
// Configuración reactiva de estilos
const estilosGraficas = ref(cargarConfiguracionDesdeCookies())
// Guardar en cookies cuando cambie la configuración
watch(estilosGraficas, (newValue) => {
const cookie = useCookie<typeof estilosGraficasDefault>('estilos-graficas', {
maxAge: 60 * 60 * 24 * 365, // 1 año
sameSite: 'lax'
})
cookie.value = newValue
}, { deep: true })
// Función para resetear la configuración
function resetearConfiguracion() {
estilosGraficas.value = { ...estilosGraficasDefault }
}
// Store de resumen de ingresos (registros por día con métricas agregadas)
const resumenIngresosStore = useTableDataStore<any>('vista_resumen_ingresos')
// Datos de resumen desde el store
const resumenIngresos = computed(() => resumenIngresosStore.allRecords)
// Definición de cosechas disponibles (8 sep - 7 sep)
const cosechasDefiniciones = [
{ id: 'cosecha-20-21', label: 'Cosecha 20-21', periodo: '8 Sep 2020 - 7 Sep 2021', fechaInicio: '2020-09-08', fechaFin: '2021-09-07' },
{ id: 'cosecha-21-22', label: 'Cosecha 21-22', periodo: '8 Sep 2021 - 7 Sep 2022', fechaInicio: '2021-09-08', fechaFin: '2022-09-07' },
{ id: 'cosecha-22-23', label: 'Cosecha 22-23', periodo: '8 Sep 2022 - 7 Sep 2023', fechaInicio: '2022-09-08', fechaFin: '2023-09-07' },
{ id: 'cosecha-23-24', label: 'Cosecha 23-24', periodo: '8 Sep 2023 - 7 Sep 2024', fechaInicio: '2023-09-08', fechaFin: '2024-09-07' },
{ id: 'cosecha-24-25', label: 'Cosecha 24-25', periodo: '8 Sep 2024 - 7 Sep 2025', fechaInicio: '2024-09-08', fechaFin: '2025-09-07' },
{ id: 'cosecha-25-26', label: 'Cosecha 25-26', periodo: '8 Sep 2025 - Hoy', fechaInicio: '2025-09-08', fechaFin: new Date().toISOString().split('T')[0] }
] as const satisfies readonly { id: string; label: string; periodo: string; fechaInicio: string; fechaFin: string }[]
// Calcular cuántos registros tiene cada cosecha
const registrosPorCosecha = computed(() => {
const counts: Record<string, number> = {}
cosechasDefiniciones.forEach(cosecha => {
const registros = resumenIngresos.value.filter((record: any) => {
const fecha = new Date(record.fecha || record.created_at)
const inicio = new Date(cosecha.fechaInicio)
const fin = new Date(cosecha.fechaFin)
return fecha >= inicio && fecha <= fin
})
counts[cosecha.id] = registros.length
})
return counts
})
// Cosechas disponibles con información de registros
const cosechasDisponibles = computed(() => {
return cosechasDefiniciones.map(cosecha => ({
...cosecha,
registros: registrosPorCosecha.value[cosecha.id] || 0,
disabled: (registrosPorCosecha.value[cosecha.id] || 0) === 0
}))
})
// Cosechas seleccionadas (persistir en cookies)
const cosechasSeleccionadas = useCookie<string[]>('comparativa-cosechas-seleccionadas', {
default: () => [],
maxAge: 60 * 60 * 24 * 365, // 1 año
sameSite: 'lax'
})
// Watch para filtrar cosechas deshabilitadas y seleccionar las más recientes con datos
watch(cosechasDisponibles, (disponibles) => {
// Remover cosechas deshabilitadas de la selección
cosechasSeleccionadas.value = cosechasSeleccionadas.value.filter(id => {
const cosecha = disponibles.find(c => c.id === id)
return cosecha && !cosecha.disabled
})
// Si no hay ninguna seleccionada, seleccionar las 2 más recientes con datos
if (cosechasSeleccionadas.value.length === 0) {
const conDatos = disponibles.filter(c => !c.disabled)
cosechasSeleccionadas.value = conDatos.slice(-2).map(c => c.id)
}
}, { immediate: true })
// Loading and error states
const loading = computed(() => resumenIngresosStore.isLoading)
const error = computed(() => resumenIngresosStore.error)
// Metadatos desde el store de metadata
const metadataStore = useMetadataStore()
const resumenIngresosMetadata = computed(() => {
const meta = metadataStore.metadata.find((t: any) => t.table === 'vista_resumen_ingresos')
return meta ? { ...meta, name: 'vista_resumen_ingresos' } : null
})
// Definir las métricas disponibles para los componentes
const metricasDisponibles = {
pesoSeco: {
key: 'total_peso_seco',
label: 'Peso Seco Total',
unidad: 'qq',
descripcion: 'Peso seco total del día'
},
pesoNetoUva: {
key: 'peso_neto_uva',
label: 'Peso Neto Uva',
unidad: 'qq',
descripcion: 'Peso neto de café uva'
},
pesoNetoVerde: {
key: 'peso_neto_verde',
label: 'Peso Neto Verde',
unidad: 'qq',
descripcion: 'Peso neto de café verde'
},
sacos: {
key: 'sacos_total_dia',
label: 'Sacos Totales',
unidad: 'sacos',
descripcion: 'Total de sacos del día'
},
lempirasUva: {
key: 'total_lempiras_uva',
label: 'Lempiras Uva',
unidad: 'L',
descripcion: 'Total en lempiras de café uva'
},
lempirasVerde: {
key: 'total_lempiras_verde',
label: 'Lempiras Verde',
unidad: 'L',
descripcion: 'Total en lempiras de café verde'
},
lempirasMojadoOreado: {
key: 'total_lempiras_mojado_oreado',
label: 'Lempiras Mojado+Oreado',
unidad: 'L',
descripcion: 'Total en lempiras de café mojado y oreado (combinados)',
// Esta métrica se calcula combinando total_lempiras_mojado + total_lempiras_oreado
computed: true,
keys: ['total_lempiras_mojado', 'total_lempiras_oreado']
}
}
// Exportar definiciones de cosechas, métricas y configuración para los componentes
// Usamos cosechasDefiniciones (array estático) en lugar de cosechasDisponibles (computed)
// porque los componentes hijos no necesitan la info de disabled/registros
provide('cosechasDisponibles', cosechasDefiniciones)
provide('metricasDisponibles', metricasDisponibles)
provide('estilosGraficas', estilosGraficas)
</script>

View File

@@ -1,378 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<UCard class="brand-card border border-transparent backdrop-blur-sm">
<template #header>
<div class="flex flex-col gap-2">
<h2 class="text-xl font-semibold text-[var(--brand-text)]">Explorador de Datos</h2>
<p class="text-sm text-[var(--brand-text-muted)]">
Explore y analice los datos de las tablas disponibles con funciones avanzadas de filtrado y ordenamiento.
</p>
</div>
</template>
<!-- Table Selection and Refresh Controls -->
<div class="flex flex-col gap-4">
<!-- Table Selector -->
<UFieldGroup>
<UButton
:label="selectedTable ? selectedTable.label : 'Seleccionar tabla'"
:icon="selectedTable ? 'i-lucide-table' : 'i-lucide-loader-circle'"
color="neutral"
variant="subtle"
:loading="metadataStore.loading && !metadataStore.hasMetadata"
/>
<UDropdownMenu :items="tableDropdownItems" :loading="metadataStore.loading && !metadataStore.hasMetadata">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-down"
:disabled="metadataStore.loading && !metadataStore.hasMetadata"
/>
</UDropdownMenu>
</UFieldGroup>
<!-- Metadata Card for selected table -->
<MetadatosCard
v-if="selectedTable"
:metadata="selectedTable"
/>
</div>
</UCard>
<!-- Loading State -->
<UCard v-if="metadataStore.loading && !metadataStore.hasMetadata" class="brand-card border border-transparent">
<div class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Cargando metadatos...</span>
</div>
<p class="text-center text-sm text-[var(--brand-text-muted)] mt-2">
Por favor espera mientras se cargan los metadatos de las tablas disponibles.
</p>
</UCard>
<!-- Table Content -->
<UCard v-else-if="selectedTable && currentTableStore" class="brand-card border border-transparent">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-semibold brand-section-title">
Tabla: {{ selectedTable.name }}
</h2>
<div class="flex flex-wrap gap-2 text-xs text-[var(--brand-text-muted)]">
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
{{ currentTableStore.recordCount }} registros cargados
</span>
<span v-if="selectedTable.rowCount" class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
{{ formatNumber(selectedTable.rowCount) }} total en BD
</span>
</div>
</div>
</template>
<!-- Loading State for Table Data -->
<div v-if="currentTableStore.isLoading && !currentTableStore.hasData" class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
</div>
<!-- Error State -->
<div v-else-if="currentTableStore.hasError" class="py-10 text-center">
<div class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200 max-w-md mx-auto">
{{ currentTableStore.error }}
</div>
<UButton
class="mt-4"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
@click="refreshTableData"
>
Reintentar
</UButton>
</div>
<!-- NuxtUI Table -->
<div v-else-if="currentTableStore.hasData" class="flex-1 divide-y divide-accented w-full">
<!-- Table Controls -->
<div class="flex items-center gap-2 px-4 py-3.5 overflow-x-auto">
<UInput
:model-value="globalFilter"
class="max-w-sm min-w-[12ch]"
placeholder="Buscar en todos los campos..."
icon="i-lucide-search"
@update:model-value="updateGlobalFilter"
/>
<UDropdownMenu
v-if="table?.tableApi"
:items="columnVisibilityItems"
:content="{ align: 'end' }"
>
<UButton
label="Columnas"
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
class="ml-auto"
aria-label="Selector de columnas visibles"
/>
</UDropdownMenu>
</div>
<!-- Table Component -->
<UTable
ref="table"
:data="currentTableStore.allRecords"
:columns="tableColumns"
:global-filter="globalFilter"
sticky
class="h-96"
/>
<!-- Table Footer with Meta Information -->
<div class="px-4 py-3.5 text-sm text-muted">
{{ filteredRowCount }} de {{ totalRowCount }} filas mostradas.
<span v-if="selectedTable.primaryKey" class="ml-2">
Clave primaria: <code class="text-xs bg-elevated px-1 py-0.5 rounded">{{ selectedTable.primaryKey }}</code>
</span>
</div>
</div>
<!-- Empty State -->
<div v-else class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
<UIcon name="i-lucide-inbox" class="mx-auto mb-4 size-12 text-[var(--brand-text-muted)]" />
<h3 class="text-lg font-semibold text-[var(--brand-text)] mb-2">No hay datos disponibles</h3>
<p class="text-sm text-[var(--brand-text-muted)] mb-4">
Haz clic en "Actualizar datos" para cargar la información de esta tabla.
</p>
<UButton
:loading="currentTableStore.isLoading"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
@click="refreshTableData"
>
<template #leading>
<UIcon name="i-lucide-refresh-cw" />
</template>
Cargar datos
</UButton>
</div>
</UCard>
<!-- No Table Selected -->
<UCard v-else class="brand-card border border-transparent">
<div class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
Selecciona una tabla para explorar sus datos.
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import { upperFirst } from 'scule'
import type { TableColumn, DropdownMenuItem } from '@nuxt/ui'
import { useMetadataStore } from '~/stores/metadata'
import { useTableDataStore } from '~/stores/tableDataFactory'
definePageMeta({
layout: 'dashboard',
title: 'Explorador de datos'
})
const UButton = resolveComponent('UButton')
// State
const selectedTableName = ref<string>('')
const globalFilter = ref('')
const currentTableStore = ref<ReturnType<typeof useTableDataStore> | null>(null)
const table = useTemplateRef('table')
const metadataStore = useMetadataStore()
const { $getTableStore } = useNuxtApp()
// Computed properties
const selectedTable = computed(() => {
if (!selectedTableName.value || !metadataStore.hasMetadata) return null
const metadata = metadataStore.getTableMetadata(selectedTableName.value)
if (!metadata) return null
return {
...metadata,
label: `${metadata.table} (${metadata.rowCount || 0} registros)`
}
})
const tableDropdownItems = computed((): DropdownMenuItem[] => {
if (metadataStore.loading && !metadataStore.hasMetadata) {
return [{
type: 'label',
label: 'Cargando...'
}]
}
if (!metadataStore.hasMetadata) {
return [{
type: 'label',
label: 'No hay tablas disponibles'
}]
}
return metadataStore.allTables.map(metadata => ({
label: `${metadata.table} (${metadata.rowCount || 0})`,
icon: 'i-lucide-table',
onSelect: () => selectTable(metadata.name)
}))
})
const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
if (!currentTableStore.value?.hasData) return []
const firstRow = currentTableStore.value.allRecords[0]
if (!firstRow) return []
const columns = Object.keys(firstRow)
return columns.map(column => ({
accessorKey: column,
header: ({ column: tableColumn }) => {
const isSorted = tableColumn.getIsSorted()
return h(UButton, {
color: 'neutral',
variant: 'ghost',
label: upperFirst(column),
icon: isSorted ? (isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow') : 'i-lucide-arrow-up-down',
class: '-mx-2.5',
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
})
},
cell: ({ row }) => formatCellValue(row.getValue(column))
}))
})
const columnVisibilityItems = computed(() => {
if (!table.value?.tableApi) return []
return table.value.tableApi
.getAllColumns()
.filter(column => column.getCanHide())
.map(column => ({
label: upperFirst(column.id),
type: 'checkbox' as const,
checked: column.getIsVisible(),
onUpdateChecked(checked: boolean) {
table.value?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
},
onSelect(e?: Event) {
e?.preventDefault()
}
}))
})
const filteredRowCount = computed(() => {
return table.value?.tableApi?.getFilteredRowModel().rows.length || 0
})
const totalRowCount = computed(() => {
return currentTableStore.value?.recordCount || 0
})
// Methods
async function selectTable(tableName: string) {
console.log(`[Explorer] selectTable called with: ${tableName}`)
if (selectedTableName.value === tableName) {
console.log(`[Explorer] Table ${tableName} already selected, skipping`)
return
}
selectedTableName.value = tableName
// Get the table store using the plugin
if (typeof $getTableStore === 'function') {
const store = $getTableStore(tableName)
if (store) {
console.log(`[Explorer] Got store for ${tableName} via plugin`)
currentTableStore.value = store
// Load from cache first (async from IndexedDB)
await store.loadFromCache()
console.log(`[Explorer] After loadFromCache, store has ${store.recordCount} records`)
// Note: We don't call initialize() here because MetadatosCard will handle loading
// initialize() would trigger a fetch, but we want manual control via the buttons
}
} else {
// Fallback: create store directly
console.log(`[Explorer] Creating store for ${tableName} directly (fallback)`)
currentTableStore.value = useTableDataStore(tableName)
await currentTableStore.value.loadFromCache()
console.log(`[Explorer] After loadFromCache, store has ${currentTableStore.value.recordCount} records`)
}
}
async function refreshTableData() {
console.log('[Explorer] refreshTableData called')
if (currentTableStore.value) {
console.log(`[Explorer] Refreshing data for current table (${selectedTableName.value})`)
await currentTableStore.value.refreshData()
console.log(`[Explorer] After refresh, store has ${currentTableStore.value.recordCount} records`)
}
}
function updateGlobalFilter(value: string) {
globalFilter.value = value
}
function formatNumber(value: number): string {
return new Intl.NumberFormat('es-ES').format(value)
}
function formatCellValue(value: unknown): string {
if (value === null || value === undefined) {
return '—'
}
if (value instanceof Date) {
return value.toISOString()
}
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return '[objeto]'
}
}
const stringValue = String(value)
// Truncate long strings
if (stringValue.length > 100) {
return stringValue.substring(0, 100) + '...'
}
return stringValue
}
// Lifecycle
onMounted(async () => {
console.log('[Explorer] onMounted: Initializing metadata store')
await metadataStore.initialize()
// Auto-select first table if available
if (metadataStore.hasMetadata && !selectedTableName.value) {
const firstTable = metadataStore.allTables[0]
if (firstTable) {
console.log(`[Explorer] Auto-selecting first table: ${firstTable.name}`)
selectTable(firstTable.name) // Use name instead of table
}
}
})
// Auto-select first table when metadata becomes available
watch(() => metadataStore.hasMetadata, (hasMetadata) => {
if (hasMetadata && !selectedTableName.value && metadataStore.allTables.length > 0) {
console.log('[Explorer] Metadata became available, auto-selecting first table')
selectTable(metadataStore.allTables[0].name) // Use name instead of table
}
})
</script>

View File

@@ -14,79 +14,42 @@
Analítica Núcleo Data Studio Analítica Núcleo Data Studio
</h1> </h1>
<p class="text-sm text-[var(--brand-text-muted)]"> <p class="text-sm text-[var(--brand-text-muted)]">
Bienvenido al panel principal. Selecciona una sección para comenzar a explorar tus datos Supabase. Panel de administración y monitoreo de Núcleo Río Frío
</p> </p>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs uppercase tracking-[0.18em]"> <span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs uppercase tracking-[0.18em]">
<span class="inline-block h-2 w-2 rounded-full bg-[#ffe0a0]"></span> <span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
Solo lectura Activo
</span>
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs uppercase tracking-[0.18em]">
Multi-fuente
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<div class="grid gap-6 md:grid-cols-2"> <div class="text-center py-12 space-y-6">
<UCard class="brand-card border border-transparent"> <div class="flex justify-center">
<template #header> <div class="w-24 h-24 rounded-full bg-gradient-to-br from-[#c08040]/20 to-[#ffe0a0]/20 flex items-center justify-center">
<div class="flex items-center gap-3"> <UIcon name="i-lucide-chart-line" class="size-12 text-[#ffe0a0]" />
<UIcon name="i-lucide-table" class="size-6 text-[#ffe0a0]" />
<div>
<h3 class="text-lg font-semibold text-[var(--brand-text)]">Explorador de datos</h3>
<p class="text-sm text-[var(--brand-text-muted)]">Consulta tablas, metadatos y registros filtrados.</p>
</div>
</div>
</template>
<template #footer>
<NuxtLink to="/explorer" class="text-sm font-semibold text-[#ffe0a0] hover:underline">
Ir al explorador
</NuxtLink>
</template>
</UCard>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-3">
<UIcon name="i-lucide-bar-chart-3" class="size-6 text-[#ffe0a0]" />
<div>
<h3 class="text-lg font-semibold text-[var(--brand-text)]">Monitoreo rápido</h3>
<p class="text-sm text-[var(--brand-text-muted)]">Visualiza indicadores clave y estado general.</p>
</div>
</div>
</template>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<p class="text-xs uppercase tracking-[0.28em] text-[var(--brand-text-muted)]">Tablas monitoreadas</p>
<p class="pt-2 text-2xl font-semibold text-[var(--brand-text)]">{{ metadataCount }}</p>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<p class="text-xs uppercase tracking-[0.28em] text-[var(--brand-text-muted)]">Última actividad</p>
<p class="pt-2 text-sm text-[var(--brand-text)]">{{ lastUpdatedText }}</p>
</div>
</div> </div>
</UCard> </div>
<div>
<h2 class="text-2xl font-bold text-[var(--brand-text)] mb-2">
Bienvenido a Analítica Núcleo
</h2>
<p class="text-lg text-[var(--brand-text-muted)] mb-4">
Sistema de análisis y monitoreo empresarial
</p>
<p class="text-sm text-[var(--brand-text-muted)] max-w-2xl mx-auto">
Panel central para la gestión y visualización de métricas de negocio de Nucleo Río Frío.
Las funcionalidades de análisis de datos están actualmente en desarrollo.
</p>
</div>
</div> </div>
</UCard> </UCard>
<section class="grid gap-6 md:grid-cols-3"> <section class="grid gap-6 md:grid-cols-3">
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-compass" class="size-5 text-[#ffe0a0]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">Cómo empezar</span>
</div>
</template>
<ol class="list-decimal space-y-2 pl-4 text-sm text-[var(--brand-text-muted)]">
<li>Abre Explorador de datos desde la barra lateral.</li>
<li>Elige el tipo de consulta y ajusta los filtros.</li>
<li>Ejecuta la solicitud para revisar los resultados.</li>
</ol>
</UCard>
<UCard class="brand-card border border-transparent"> <UCard class="brand-card border border-transparent">
<template #header> <template #header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -95,43 +58,46 @@
</div> </div>
</template> </template>
<p class="text-sm text-[var(--brand-text-muted)]"> <p class="text-sm text-[var(--brand-text-muted)]">
Este panel opera con credenciales de solo lectura hacia Supabase. No se exponen operaciones de escritura Acceso protegido mediante Authentik con autenticación de doble factor y gestión de permisos basada en grupos.
ni se almacenan datos sensibles en el cliente.
</p> </p>
</UCard> </UCard>
<UCard class="brand-card border border-transparent"> <UCard class="brand-card border border-transparent">
<template #header> <template #header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UIcon name="i-lucide-life-buoy" class="size-5 text-[#ffe0a0]" /> <UIcon name="i-lucide-user" class="size-5 text-[#ffe0a0]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">¿Necesitas ayuda?</span> <span class="text-sm font-semibold text-[var(--brand-text)]">Mi Perfil</span>
</div> </div>
</template> </template>
<p class="text-sm text-[var(--brand-text-muted)]"> <p class="text-sm text-[var(--brand-text-muted)] mb-3">
Consulta la documentación interna o abre un ticket de soporte para integrar nuevas fuentes, automatizar Gestiona tu información personal y preferencias del sistema.
reportes o resolver incidentes.
</p> </p>
<NuxtLink to="/profile" class="text-sm font-semibold text-[#ffe0a0] hover:underline">
Ver perfil
</NuxtLink>
</UCard>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-settings" class="size-5 text-[#ffe0a0]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">Configuración</span>
</div>
</template>
<p class="text-sm text-[var(--brand-text-muted)] mb-3">
Personaliza tu experiencia y ajusta las preferencias del panel.
</p>
<NuxtLink to="/settings" class="text-sm font-semibold text-[#ffe0a0] hover:underline">
Ir a configuración
</NuxtLink>
</UCard> </UCard>
</section> </section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
definePageMeta({ definePageMeta({
layout: 'dashboard', layout: 'dashboard',
title: 'Inicio' title: 'Inicio'
}) })
const availableMetadata = useState('availableMetadataSummary', () => [])
const lastUpdated = useState<string>('dashboardLastUpdated', () => new Date().toISOString())
const metadataCount = computed(() => availableMetadata.value.length || '—')
const lastUpdatedText = computed(() => {
if (!lastUpdated.value) return 'Sin registros'
const date = new Date(lastUpdated.value)
return Number.isNaN(date.getTime()) ? lastUpdated.value : date.toLocaleString()
})
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,171 +0,0 @@
<template>
<div class="flex flex-col gap-8">
<UCard class="brand-card border border-transparent backdrop-blur-sm">
<template #header>
<div class="flex flex-col gap-2">
<h2 class="text-xl font-semibold text-[var(--brand-text)]">Metadatos de Tablas</h2>
<p class="text-sm text-[var(--brand-text-muted)]">
Información detallada sobre las tablas disponibles en la base de datos.
</p>
</div>
</template>
<div class="flex flex-col gap-4">
<!-- Refresh Controls -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-[var(--brand-text)]">
Última actualización: {{ metadataStore.formattedLastUpdated }}
</span>
<span v-if="metadataStore.isStale" class="text-xs text-yellow-400">
Los metadatos pueden estar desactualizados
</span>
</div>
<UButton
:loading="metadataStore.loading"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
@click="refreshMetadata"
>
<template #leading>
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': metadataStore.loading }" />
</template>
Actualizar metadatos
</UButton>
</div>
<!-- Stats Summary -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ metadataStore.totalTables }}</div>
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Tablas totales</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ formatNumber(metadataStore.totalRecords) }}</div>
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Registros totales</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ formatSize(totalSize) }}</div>
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Tamaño aproximado</div>
</div>
</div>
</div>
</UCard>
<!-- Error State -->
<div v-if="metadataStore.hasError" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
{{ metadataStore.error }}
</div>
<!-- Loading State -->
<UCard v-if="metadataStore.loading && !metadataStore.hasMetadata" class="brand-card border border-transparent">
<div class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Cargando metadatos...</span>
</div>
</UCard>
<!-- Metadata Cards -->
<section v-if="metadataStore.hasMetadata" class="flex flex-col gap-5">
<div v-if="metadataStore.allTables.length" class="grid gap-5 md:grid-cols-2">
<MetadatosCard
v-for="meta in metadataStore.allTables"
:key="meta.table"
:metadata="meta"
/>
</div>
<!-- Sample Row Card (if available) -->
<template v-for="meta in metadataStore.allTables.filter(m => m.sampleRow)" :key="`sample-${meta.table}`">
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-lg font-semibold brand-section-title">Registro de ejemplo - {{ meta.table }}</h2>
</template>
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">{{ formatSample(meta.sampleRow) }}</pre>
</UCard>
</template>
</section>
<!-- Empty State -->
<UCard v-else-if="!metadataStore.loading" class="brand-card border border-transparent">
<div class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
<UIcon name="i-lucide-database-zap" class="mx-auto mb-4 size-12 text-[var(--brand-text-muted)]" />
<h3 class="text-lg font-semibold text-[var(--brand-text)] mb-2">No hay metadatos disponibles</h3>
<p class="text-sm text-[var(--brand-text-muted)] mb-4">
Haz clic en "Actualizar metadatos" para cargar la información de las tablas.
</p>
<UButton
:loading="metadataStore.loading"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
@click="refreshMetadata"
>
<template #leading>
<UIcon name="i-lucide-refresh-cw" />
</template>
Cargar metadatos
</UButton>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
import { useMetadataStore } from '~/stores/metadata'
definePageMeta({
layout: 'dashboard',
title: 'Metadatos'
})
const metadataStore = useMetadataStore()
// Computed properties
const totalSize = computed(() => {
return metadataStore.allTables.reduce((sum, meta) => sum + (meta.approxSizeBytes || 0), 0)
})
// Methods
async function refreshMetadata() {
await metadataStore.refreshMetadata()
}
function formatSize(bytes: number | null | undefined): string {
if (!bytes) {
return 'No disponible'
}
if (bytes < 1024) {
return `${bytes} B`
}
const units = ['KB', 'MB', 'GB', 'TB']
let size = bytes / 1024
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
function formatNumber(value: number): string {
return new Intl.NumberFormat('es-ES').format(value)
}
function formatSample(value: unknown): string {
try {
return JSON.stringify(value, null, 2)
} catch (error) {
return String(value)
}
}
// Initialize store on component mount
onMounted(async () => {
await metadataStore.initialize()
})
</script>

View File

@@ -1,359 +0,0 @@
<!-- nuxt4-app/app/pages/panorama.vue -->
<template>
<div class="flex flex-col gap-8">
<!-- Loading State -->
<UCard v-if="loading && !ingresosStore.hasData" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
<div class="flex items-center gap-3">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
</div>
<UProgress
v-if="loadingProgress > 0"
:model-value="loadingProgress"
:max="100"
size="sm"
class="w-64"
/>
<span v-if="loadingProgress > 0" class="text-xs text-[var(--brand-text-muted)]">
{{ Math.round(loadingProgress) }}%
</span>
</div>
</UCard>
<!-- Error State -->
<div v-else-if="error" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
<p>Error al cargar datos: {{ error }}</p>
</div>
<!-- Main Content -->
<template v-else>
<!-- Metadatos Cards de Ingresos y Rechazos -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" :compact="true" />
<MetadatosCard v-if="rechazosMetadata" :metadata="rechazosMetadata" :compact="true" />
</div>
<!-- 🔻 Card de Filtros -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-bold brand-section-title">Filtros y Configuraciones</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
Aplicados a <code>created_at</code> de ingresos y rechazos
</p>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="includeAnulados" label="Incluir anulados" @update:model-value="onToggleAnulados" />
</div>
</div>
<!-- Alerta roja cuando incluye anulados -->
<UAlert
v-if="includeAnulados"
color="error"
variant="solid"
icon="i-lucide-alert-triangle"
title="Incluir anulados activado"
description="Los cálculos incluyen registros anulados. Esto puede afectar los resultados financieros."
/>
</div>
</template>
<DateRangeSelector
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
@update:selected-preset="selectedPreset = $event"
@update:fecha-desde="fechaDesde = $event"
@update:fecha-hasta="fechaHasta = $event"
/>
<template #footer>
<div class="text-xs text-[var(--brand-text-muted)]">
Rango activo: {{ rangoLegible }} · Registros considerados: Ingresos {{ ingresosFiltrados.length }}/{{ ingresos.length }} · Rechazos {{ rechazosFiltrados.length }}/{{ rechazos.length }}
</div>
</template>
</UCard>
<!-- Totales Financieros - Resumen Principal -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold brand-section-title">Totales Financieros</h2>
<p class="text-sm text-[var(--brand-text-muted)] mt-1">Vista general de ingresos, inversiones y rechazos</p>
</div>
<UButton
:loading="refreshing"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
size="sm"
@click="refreshData"
>
<template #leading>
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': refreshing }" />
</template>
Actualizar
</UButton>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-6 py-4">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Total Invertido en Café</div>
<div class="text-3xl font-bold text-[var(--brand-primary)]">
{{ formatCurrency(ingresosMetrics.totalInvertido.value + ingresosMetrics.inversionVerdeHastaFecha.value) }}
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-6 py-4">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Total Rechazos</div>
<div class="text-3xl font-bold text-green-400">
{{ formatCurrency(rechazosMetrics.totalRechazos.value) }}
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-6 py-4">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Balance Neto</div>
<div class="text-3xl font-bold text-[var(--brand-text)]">
{{ formatCurrency(ingresosMetrics.totalInvertido.value + ingresosMetrics.inversionVerdeHastaFecha.value - rechazosMetrics.totalRechazos.value) }}
</div>
</div>
</div>
<template #footer>
<div class="text-xs text-[var(--brand-text-muted)]">
Última actualización: {{ lastUpdated }}
</div>
</template>
</UCard>
<!-- Ingresos Sections -->
<IngresosSecosVendidos :metrics="ingresosMetrics" />
<IngresosTotalesIngresoCompra :metrics="ingresosMetrics" />
<IngresosTotalesMonetarios :metrics="ingresosMetrics" />
<IngresosTotalesVerde :metrics="ingresosMetrics" />
<!-- Rechazos Section -->
<RechazosSubproductos :metrics="rechazosMetrics" />
</template>
</div>
</template>
<script setup lang="ts">
import { useTableDataStore } from '~/stores/tableDataFactory'
import { useMetadataStore } from '~/stores/metadata'
import { useIngresosMetrics } from '~/composables/useIngresosMetrics'
import { useRechazosMetrics } from '~/composables/useRechazosMetrics'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
import type { RechazoRecord } from '~/composables/useRechazosMetrics'
// Define page metadata
definePageMeta({
layout: 'informe',
title: 'Panorama Facturador'
})
// Initialize stores
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
const rechazosStore = useTableDataStore<RechazoRecord>('rechazos')
// Reactive data from stores (sin filtrar)
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
const rechazos = computed(() => rechazosStore.allRecords as RechazoRecord[])
// -------------------------------
// Filtros
// -------------------------------
const includeAnulados = ref(false)
type PresetValue =
| '' | 'custom' | 'hoy' | 'semana' | 'mes' | 'ytd'
| 'cosecha-20-21' | 'cosecha-21-22' | 'cosecha-22-23'
| 'cosecha-23-24' | 'cosecha-24-25' | 'cosecha-25-26';
const selectedPreset = ref<PresetValue>('cosecha-25-26')
const fechaDesde = ref<string | null>(null)
const fechaHasta = ref<string | null>(null)
async function onToggleAnulados(newValue: boolean | 'indeterminate') {
if (newValue === true) {
// Pedir confirmación al activar
const confirmed = confirm(
'⚠️ ADVERTENCIA\n\n' +
'Está a punto de incluir registros ANULADOS en los cálculos.\n\n' +
'Esto puede afectar significativamente los resultados financieros y métricas.\n\n' +
'¿Está seguro de que desea continuar?'
)
if (!confirmed) {
// Si cancela, revertir el cambio
includeAnulados.value = false
console.log('User cancelled including anulados')
} else {
console.log('User confirmed including anulados')
}
} else {
// Al desactivar, no pedir confirmación
console.log('Anulados disabled')
}
}
const rangoLegible = computed(() => {
if (!fechaDesde.value && !fechaHasta.value) return 'Sin filtro de fecha'
const f = fechaDesde.value ?? '—'
const t = fechaHasta.value ?? '—'
return `${f}${t}`
})
function isAnulado(row: any): boolean {
const estado = (row?.estado ?? '').toString().toLowerCase()
const fechaAn = row?.fecha_anulado ?? null
return estado === 'anulado' || !!fechaAn
}
function isWithinDate(row: any, from?: string | null, to?: string | null): boolean {
const created = row?.created_at ? new Date(row.created_at) : null
if (!created || isNaN(created.getTime())) return false
if (from) {
const fd = new Date(from + 'T00:00:00-06:00')
if (created < fd) return false
}
if (to) {
const td = new Date(to + 'T23:59:59-06:00')
if (created > td) return false
}
return true
}
// Filtrados que alimentan los métricos
const ingresosFiltrados = computed(() => {
return (ingresos.value ?? [])
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
})
const rechazosFiltrados = computed(() => {
return (rechazos.value ?? [])
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
})
// Métricos basados en filtrados
const ingresosMetrics = useIngresosMetrics(ingresosFiltrados)
const rechazosMetrics = useRechazosMetrics(rechazosFiltrados)
// Loading and error states
const loading = computed(() => ingresosStore.isLoading || rechazosStore.isLoading)
const error = computed(() => ingresosStore.error || rechazosStore.error)
const refreshing = ref(false)
const loadingProgress = ref(0)
// Last updated time
const lastUpdated = computed(() => {
const ingresosDate = ingresosStore.lastUpdated
const rechazosDate = rechazosStore.lastUpdated
if (!ingresosDate && !rechazosDate) return 'Nunca'
const latest = [ingresosDate, rechazosDate].filter(Boolean).sort().reverse()[0] as string | undefined
if (!latest) return 'Nunca'
return new Date(latest).toLocaleString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
})
// Format currency helper
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
// Metadatos desde el store de metadata
const metadataStore = useMetadataStore()
const ingresosMetadata = computed(() => {
const meta = metadataStore.metadata.find((t: any) => t.table === 'vista_detalle_ingresos')
return meta ? { ...meta, name: 'ingresos' } : null
})
const rechazosMetadata = computed(() => {
const meta = metadataStore.metadata.find((t: any) => t.table === 'rechazos')
return meta ? { ...meta, name: 'rechazos' } : null
})
// Refresh data
async function refreshData() {
refreshing.value = true
loadingProgress.value = 0
try {
let ingresosProgress = 0
let rechazosProgress = 0
await Promise.all([
ingresosStore.loadAllDataInBatches((progress) => {
ingresosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
}),
rechazosStore.loadAllDataInBatches((progress) => {
rechazosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
})
])
} catch (err) {
console.error('Error refreshing data:', err)
} finally {
refreshing.value = false
loadingProgress.value = 0
}
}
// Load data on mount + default preset
onMounted(async () => {
try {
// Cargar metadatos primero
if (metadataStore.metadata.length === 0) {
await (metadataStore as any).loadMetadata()
}
// Cache primero para UX
await Promise.all([
ingresosStore.loadFromCache(),
rechazosStore.loadFromCache()
])
// Si falta data, cargar en lotes
if (!ingresosStore.hasData || !rechazosStore.hasData) {
loadingProgress.value = 0
let ingresosProgress = 0
let rechazosProgress = 0
await Promise.all([
ingresosStore.loadAllDataInBatches((progress) => {
ingresosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
}),
rechazosStore.loadAllDataInBatches((progress) => {
rechazosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
})
])
loadingProgress.value = 0
}
} catch (err) {
console.error('Error loading data:', err)
} finally {
// Default preset: cosecha 25-26
selectedPreset.value = 'cosecha-25-26'
includeAnulados.value = false
}
})
</script>

View File

@@ -1,795 +0,0 @@
<template>
<div class="flex flex-col gap-8">
<UCard class="brand-card border border-transparent backdrop-blur-sm">
<template #header>
<div class="flex flex-col gap-2">
<h2 class="text-xl font-semibold text-[var(--brand-text)]">Constructor de consultas</h2>
<p class="text-sm text-[var(--brand-text-muted)]">
Arma solicitudes a los endpoints del backend y visualiza los resultados en modo lectura.
</p>
</div>
</template>
<form class="flex flex-col gap-6" @submit.prevent="executeRequest">
<div class="grid gap-4 lg:grid-cols-4">
<UFormField label="Tipo de consulta" name="type">
<USelectMenu v-model="request.type" :items="requestTypeOptions" value-key="value" />
</UFormField>
<UFormField label="Ámbito" name="scope">
<USelectMenu v-model="request.scope" :items="scopeOptions" value-key="value" />
</UFormField>
<UFormField v-if="requiresTable" label="Tabla" name="table">
<USelectMenu
v-model="request.table"
:items="tableOptions"
value-key="value"
placeholder="Selecciona una tabla"
/>
</UFormField>
<UFormField v-if="showsLimit" label="Límite" name="limit">
<UInput v-model.number="request.limit" type="number" min="1" max="500" />
</UFormField>
<UFormField v-if="requiresRecordId" label="ID del registro" name="recordId">
<UInput v-model="request.recordId" placeholder="Introduce el ID exacto" />
</UFormField>
<UFormField v-if="showsIdFilter" label="Filtrar por ID" name="filterId">
<UInput v-model="request.filterId" placeholder="Opcional" />
</UFormField>
<UFormField v-if="showsDateFilters" label="Fecha desde" name="createdFrom">
<UInput v-model="request.createdFrom" type="date" />
</UFormField>
<UFormField v-if="showsDateFilters" label="Fecha hasta" name="createdTo">
<UInput v-model="request.createdTo" type="date" />
</UFormField>
</div>
<div v-if="showQueryJson" class="space-y-2">
<UFormField label="Consulta avanzada (JSON)" name="queryJson">
<UTextarea
v-model="request.queryJson"
:rows="5"
placeholder='{ "filters": [{ "field": "estado", "operator": "eq", "value": "activo" }] }'
/>
</UFormField>
<p v-if="queryState.error" class="text-sm text-red-300">{{ queryState.error }}</p>
<p v-else-if="queryState.encoded" class="text-xs text-[var(--brand-text-muted)]">
Segmento codificado:
<code class="rounded bg-[#2a2014] px-2 py-1 text-[var(--brand-accent)]">{{ queryState.encoded }}</code>
</p>
<p class="text-xs text-[var(--brand-text-muted)]">
Se codifica automáticamente en base64-url para construir la ruta
<code class="rounded bg-[#2a2014] px-2 py-1 text-[var(--brand-accent)]">
/api/data/{{ request.table || ':tabla' }}/{{ queryState.encoded || ':query' }}
</code>.
</p>
</div>
<div class="flex flex-col gap-3 rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3 shadow-inner shadow-black/30">
<span class="text-xs font-semibold uppercase tracking-[0.28em] text-[var(--brand-text-muted)]">
Solicitud generada
</span>
<code class="break-all text-sm text-[var(--brand-accent)]">GET {{ requestPreview }}</code>
</div>
<div class="flex justify-end gap-2">
<UButton
variant="soft"
:ui="{ base: 'bg-transparent border border-[#3a2a16] text-[var(--brand-text-muted)] hover:bg-[#2a2014] hover:border-[#c08040]/60' }"
@click="resetForm"
:disabled="loading"
>
Limpiar
</UButton>
<UButton
type="submit"
:loading="loading"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
>
Ejecutar consulta
</UButton>
</div>
</form>
</UCard>
<div v-if="errorMessage" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
{{ errorMessage }}
</div>
<section v-if="hasMetadataResponse" class="flex flex-col gap-5">
<div v-if="metadataCollection.length" class="grid gap-5 md:grid-cols-2">
<UCard v-for="meta in metadataCollection" :key="meta.table" class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold brand-section-title">Tabla {{ meta.table }}</h2>
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
{{ meta.rowCount }} registros
</span>
</div>
</template>
<dl class="grid grid-cols-2 gap-3 text-sm text-[var(--brand-text-muted)]">
<div>
<dt class="uppercase tracking-wide text-xs">Clave primaria</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ meta.primaryKey }}</dd>
</div>
<div>
<dt class="uppercase tracking-wide text-xs">Tamaño aprox.</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ formatSize(meta.approxSizeBytes) }}</dd>
</div>
<div>
<dt class="uppercase tracking-wide text-xs">Creación desde</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(meta.createdAtRange?.from) }}</dd>
</div>
<div>
<dt class="uppercase tracking-wide text-xs">Creación hasta</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(meta.createdAtRange?.to) }}</dd>
</div>
</dl>
<template #footer>
<div class="brand-divider pt-3 text-xs text-[var(--brand-text-muted)]">
Columnas detectadas: {{ meta.columns.join(', ') }}
</div>
</template>
</UCard>
</div>
<div v-else-if="activeMetadata" class="grid gap-5 md:grid-cols-2">
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold brand-section-title">Resumen de {{ activeMetadata.table }}</h2>
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
{{ activeMetadata.rowCount }} registros
</span>
</div>
</template>
<dl class="grid grid-cols-2 gap-3 text-sm text-[var(--brand-text-muted)]">
<div>
<dt class="uppercase tracking-wide text-xs">Clave primaria</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ activeMetadata.primaryKey }}</dd>
</div>
<div>
<dt class="uppercase tracking-wide text-xs">Última consulta</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(activeMetadata.lastRefreshed) }}</dd>
</div>
<div>
<dt class="uppercase tracking-wide text-xs">Tamaño aprox.</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ formatSize(activeMetadata.approxSizeBytes) }}</dd>
</div>
<div>
<dt class="uppercase tracking-wide text-xs">Rango de creación</dt>
<dd class="font-medium text-[var(--brand-text)]">
{{ formatDate(activeMetadata.createdAtRange?.from) }} {{ formatDate(activeMetadata.createdAtRange?.to) }}
</dd>
</div>
</dl>
<template #footer>
<div class="brand-divider pt-3 text-xs text-[var(--brand-text-muted)]">
Columnas detectadas: {{ activeMetadata.columns.join(', ') }}
</div>
</template>
</UCard>
<UCard v-if="activeMetadata.sampleRow" class="brand-card border border-transparent">
<template #header>
<h2 class="text-lg font-semibold brand-section-title">Registro de ejemplo</h2>
</template>
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
{{ formatSample(activeMetadata.sampleRow) }}
</pre>
</UCard>
</div>
<UCard v-if="metadataRecord" class="brand-card border border-transparent">
<template #header>
<h2 class="text-lg font-semibold brand-section-title">Metadata del registro {{ metadataRecord.id }}</h2>
</template>
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
{{ formatSample(metadataRecord.metadata) }}
</pre>
</UCard>
</section>
<UCard v-if="request.type === 'data' || hasDataResponse" class="brand-card border border-transparent">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-lg font-semibold brand-section-title">Datos</h2>
<div class="flex flex-wrap gap-2 text-xs text-[var(--brand-text-muted)]">
<template v-if="dataStats">
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
{{ dataStats.table }}: {{ dataStats.count }} registros (límite {{ dataStats.limit ?? 's/d' }})
</span>
</template>
<template v-else-if="dataStatsCollection.length">
<span
v-for="item in dataStatsCollection"
:key="item.table"
class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs"
>
{{ item.table }}: {{ item.count }} registros (límite {{ item.limit ?? 's/d' }})
</span>
</template>
<span v-else-if="tableData.length" class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
{{ tableData.length }} registros visibles
</span>
</div>
</div>
</template>
<div v-if="loading" class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Procesando</span>
</div>
<div v-else-if="!hasDataResponse" class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
Ejecuta una consulta de datos para ver resultados aquí.
</div>
<div v-else-if="tableData.length === 0" class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
No se encontraron registros para los criterios seleccionados.
</div>
<div v-else class="overflow-auto">
<table class="brand-table min-w-full divide-y divide-[#3a2a16]/60 text-sm">
<thead>
<tr>
<th
v-for="column in visibleColumns"
:key="column"
class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.18em] text-[var(--brand-text-muted)]"
>
{{ column }}
</th>
</tr>
</thead>
<tbody class="brand-table divide-y divide-[#3a2a16]/40">
<tr v-for="(row, index) in tableData" :key="index" class="transition-colors">
<td v-for="column in visibleColumns" :key="column" class="px-4 py-2 text-sm text-[var(--brand-text-muted)]">
{{ formatCell(row[column]) }}
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
<UCard v-if="rawResponse" class="brand-card border border-transparent">
<template #header>
<h2 class="text-lg font-semibold brand-section-title">Respuesta cruda (JSON)</h2>
</template>
<pre class="max-h-96 overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
{{ formatSample(rawResponse) }}
</pre>
</UCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRequestFetch } from '#imports'
definePageMeta({
layout: 'dashboard',
title: 'Explorador de datos'
})
type RequestType = 'data' | 'metadata'
type MetadataScope = 'all' | 'table' | 'record'
type DataScope = 'all' | 'table' | 'record' | 'query'
type RequestScope = MetadataScope | DataScope
interface Option<T extends string> {
label: string
value: T
}
const requestTypeOptions: Option<RequestType>[] = [
{ label: 'Datos', value: 'data' },
{ label: 'Metadatos', value: 'metadata' }
]
const metadataScopeOptions: Option<MetadataScope>[] = [
{ label: 'Todas las tablas', value: 'all' },
{ label: 'Por tabla', value: 'table' },
{ label: 'Registro específico', value: 'record' }
]
const dataScopeOptions: Option<DataScope>[] = [
{ label: 'Todas las tablas', value: 'all' },
{ label: 'Por tabla', value: 'table' },
{ label: 'Registro específico', value: 'record' },
{ label: 'Consulta avanzada', value: 'query' }
]
const DEFAULT_METADATA_SCOPE: MetadataScope = 'all'
const DEFAULT_DATA_SCOPE: DataScope = 'table'
const requestFetch = useRequestFetch()
const request = reactive<{
type: RequestType
scope: RequestScope
table: string
recordId: string
filterId: string
createdFrom: string
createdTo: string
limit: number
queryJson: string
}>(
{
type: 'data',
scope: DEFAULT_DATA_SCOPE,
table: '',
recordId: '',
filterId: '',
createdFrom: '',
createdTo: '',
limit: 100,
queryJson: ''
}
)
const loading = ref(false)
const errorMessage = ref<string | null>(null)
const rawResponse = ref<unknown>(null)
const availableMetadata = ref<any[]>([])
const metadataCollection = ref<any[]>([])
const metadataRecord = ref<any | null>(null)
const activeMetadata = ref<any | null>(null)
const tableData = ref<Record<string, unknown>[]>([])
const dataStats = ref<{ table: string; count: number; limit?: number | null } | null>(null)
const dataStatsCollection = ref<{ table: string; count: number; limit?: number | null }[]>([])
const hasDataResponse = ref(false)
const hasMetadataResponse = ref(false)
const scopeOptions = computed(() => (request.type === 'metadata' ? metadataScopeOptions : dataScopeOptions))
const requiresTable = computed(() => request.scope !== 'all')
const requiresRecordId = computed(() => request.scope === 'record')
const showsLimit = computed(
() => request.type === 'data' && (request.scope === 'all' || request.scope === 'table' || request.scope === 'query')
)
const showsDateFilters = computed(() => request.type === 'data' && request.scope === 'table')
const showsIdFilter = computed(() => request.type === 'data' && request.scope === 'table')
const showQueryJson = computed(() => request.type === 'data' && request.scope === 'query')
const tableOptions = computed(() =>
availableMetadata.value.map((meta) => ({
label: `${meta.table} (${meta.rowCount ?? '—'})`,
value: meta.table
}))
)
const visibleColumns = computed(() => (tableData.value[0] ? Object.keys(tableData.value[0]) : []))
const queryState = computed(() => {
if (!showQueryJson.value) {
return { encoded: '', error: null as string | null }
}
const trimmed = request.queryJson.trim()
if (!trimmed) {
return { encoded: '', error: null as string | null }
}
try {
const parsed = JSON.parse(trimmed)
const normalized = JSON.stringify(parsed)
return { encoded: encodeBase64Url(normalized), error: null as string | null }
} catch (error) {
return { encoded: '', error: 'El JSON proporcionado no es válido.' }
}
})
const requestPreview = computed(() => {
const base = request.type === 'metadata' ? '/api/metadata' : '/api/data'
let path = base
const params = new URLSearchParams()
const limit = sanitizeLimit(request.limit)
if (request.type === 'metadata') {
if (request.scope === 'table') {
path += `/${request.table || ':tabla'}`
} else if (request.scope === 'record') {
path += `/${request.table || ':tabla'}/${request.recordId || ':id'}`
}
} else {
if (request.scope === 'all') {
params.set('limit', String(limit))
} else if (request.scope === 'table') {
path += `/${request.table || ':tabla'}`
params.set('limit', String(limit))
if (request.filterId) params.set('id', request.filterId.trim())
if (request.createdFrom) params.set('created_from', request.createdFrom)
if (request.createdTo) params.set('created_to', request.createdTo)
} else if (request.scope === 'record') {
path += `/${request.table || ':tabla'}/${request.recordId || ':id'}`
} else if (request.scope === 'query') {
const segment = queryState.value.encoded || ':query-base64'
path += `/${request.table || ':tabla'}/${segment}`
params.set('limit', String(limit))
}
}
const queryString = params.toString()
return queryString ? `${path}?${queryString}` : path
})
onMounted(async () => {
await loadAvailableMetadata()
})
watch(
() => request.type,
(type) => {
request.scope = type === 'metadata' ? DEFAULT_METADATA_SCOPE : DEFAULT_DATA_SCOPE
request.recordId = ''
request.filterId = ''
request.createdFrom = ''
request.createdTo = ''
request.queryJson = ''
clearResults()
if (requiresTable.value && !request.table && availableMetadata.value.length > 0) {
request.table = availableMetadata.value[0].table
}
}
)
watch(
() => request.scope,
() => {
if (!requiresTable.value) {
request.table = ''
} else if (!request.table && availableMetadata.value.length > 0) {
request.table = availableMetadata.value[0].table
}
if (!requiresRecordId.value) {
request.recordId = ''
}
if (!showsIdFilter.value) {
request.filterId = ''
}
if (!showsDateFilters.value) {
request.createdFrom = ''
request.createdTo = ''
}
}
)
async function loadAvailableMetadata() {
try {
const metadata = await requestFetch('/api/metadata')
if (Array.isArray(metadata)) {
availableMetadata.value = metadata
useState('availableMetadataSummary', () => metadata).value = metadata
useState('dashboardLastUpdated', () => new Date().toISOString()).value = new Date().toISOString()
if (!request.table && requiresTable.value && metadata.length > 0) {
request.table = metadata[0].table
}
}
} catch (error) {
errorMessage.value = extractErrorMessage(error)
}
}
function sanitizeLimit(value: number) {
if (!Number.isFinite(value)) {
return 100
}
return Math.max(1, Math.min(500, Math.trunc(value)))
}
function encodeBase64Url(value: string) {
if (typeof globalThis !== 'undefined' && typeof globalThis.btoa === 'function') {
const encoded = globalThis.btoa(
encodeURIComponent(value).replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
)
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
return Buffer.from(value, 'utf-8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function clearResults() {
metadataCollection.value = []
metadataRecord.value = null
activeMetadata.value = null
tableData.value = []
dataStats.value = null
dataStatsCollection.value = []
rawResponse.value = null
hasDataResponse.value = false
hasMetadataResponse.value = false
}
function resetForm() {
request.scope = request.type === 'metadata' ? DEFAULT_METADATA_SCOPE : DEFAULT_DATA_SCOPE
request.table = availableMetadata.value[0]?.table ?? ''
request.recordId = ''
request.filterId = ''
request.createdFrom = ''
request.createdTo = ''
request.limit = 100
request.queryJson = ''
clearResults()
}
function buildExecutableRequest() {
const base = request.type === 'metadata' ? '/api/metadata' : '/api/data'
let path = base
const query: Record<string, string> = {}
if (request.type === 'metadata') {
if (request.scope === 'all') {
return { url: path, query, error: null as string | null }
}
if (!request.table) {
return { url: '', query, error: 'Selecciona una tabla antes de ejecutar la consulta.' }
}
path += `/${request.table}`
if (request.scope === 'record') {
if (!request.recordId.trim()) {
return { url: '', query, error: 'Introduce el ID del registro que deseas consultar.' }
}
path += `/${request.recordId.trim()}`
}
return { url: path, query, error: null }
}
if (request.scope === 'all') {
query.limit = String(sanitizeLimit(request.limit))
return { url: path, query, error: null }
}
if (!request.table) {
return { url: '', query, error: 'Selecciona una tabla antes de ejecutar la consulta.' }
}
if (request.scope === 'table') {
path += `/${request.table}`
query.limit = String(sanitizeLimit(request.limit))
if (request.filterId) {
query.id = request.filterId.trim()
}
if (request.createdFrom) {
query.created_from = request.createdFrom
}
if (request.createdTo) {
query.created_to = request.createdTo
}
return { url: path, query, error: null }
}
if (request.scope === 'record') {
if (!request.recordId.trim()) {
return { url: '', query, error: 'Introduce el ID del registro que deseas consultar.' }
}
path += `/${request.table}/${request.recordId.trim()}`
return { url: path, query, error: null }
}
if (!request.queryJson.trim()) {
return { url: '', query, error: 'Introduce un JSON para la consulta avanzada.' }
}
if (queryState.value.error) {
return { url: '', query, error: queryState.value.error }
}
path += `/${request.table}/${queryState.value.encoded}`
query.limit = String(sanitizeLimit(request.limit))
return { url: path, query, error: null }
}
async function executeRequest() {
const requestConfig = buildExecutableRequest()
if (requestConfig.error) {
errorMessage.value = requestConfig.error
return
}
loading.value = true
errorMessage.value = null
try {
const response = await requestFetch(requestConfig.url, {
query: requestConfig.query
})
processResponse(response)
rawResponse.value = response
} catch (error) {
clearResults()
errorMessage.value = extractErrorMessage(error)
} finally {
loading.value = false
}
}
function processResponse(response: unknown) {
metadataCollection.value = []
metadataRecord.value = null
activeMetadata.value = null
tableData.value = []
dataStats.value = null
dataStatsCollection.value = []
if (request.type === 'metadata') {
hasMetadataResponse.value = true
if (Array.isArray(response)) {
metadataCollection.value = response
availableMetadata.value = response
useState('availableMetadataSummary', () => response).value = response
useState('dashboardLastUpdated', () => new Date().toISOString()).value = new Date().toISOString()
} else if (request.scope === 'record' && response && typeof response === 'object') {
metadataRecord.value = response
} else {
activeMetadata.value = response
if (response && typeof response === 'object' && 'table' in response) {
const index = availableMetadata.value.findIndex((item) => item.table === (response as any).table)
if (index >= 0) {
availableMetadata.value[index] = response
}
}
}
return
}
hasDataResponse.value = true
if (request.scope === 'all' && Array.isArray(response)) {
const datasets = response as Array<{
table: string
count?: number | null
limit?: number | null
records?: Record<string, unknown>[]
}>
dataStatsCollection.value = datasets.map((item) => ({
table: item.table,
count: item.count ?? item.records?.length ?? 0,
limit: item.limit ?? null
}))
tableData.value = datasets.flatMap((item) => {
const rows = Array.isArray(item.records) ? item.records : []
return rows.map((row) => ({ __tabla: item.table, ...row }))
})
return
}
if (request.scope === 'record') {
tableData.value = response ? [response as Record<string, unknown>] : []
dataStats.value = {
table: request.table,
count: tableData.value.length,
limit: null
}
return
}
if (response && typeof response === 'object' && 'records' in response) {
const dataset = response as {
table: string
count?: number | null
limit?: number | null
records?: Record<string, unknown>[]
}
const rows = Array.isArray(dataset.records) ? dataset.records : []
tableData.value = rows
dataStats.value = {
table: dataset.table,
count: dataset.count ?? rows.length,
limit: dataset.limit ?? null
}
return
}
tableData.value = Array.isArray(response) ? (response as Record<string, unknown>[]) : []
}
function extractErrorMessage(error: unknown) {
if (error && typeof error === 'object' && 'statusMessage' in error) {
return String((error as { statusMessage: string }).statusMessage)
}
if (error instanceof Error) {
return error.message
}
return 'Ocurrió un error inesperado al consultar los datos.'
}
function formatSize(bytes: number | null | undefined) {
if (!bytes) {
return 'No disponible'
}
if (bytes < 1024) {
return `${bytes} B`
}
const units = ['KB', 'MB', 'GB']
let size = bytes / 1024
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
function formatDate(value: string | null | undefined) {
if (!value) {
return '—'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString()
}
function formatCell(value: unknown) {
if (value === null || value === undefined) {
return '—'
}
if (value instanceof Date) {
return value.toISOString()
}
if (typeof value === 'object') {
try {
return JSON.stringify(value)
} catch (error) {
return '[objeto]'
}
}
return String(value)
}
function formatSample(value: unknown) {
try {
return JSON.stringify(value, null, 2)
} catch (error) {
return String(value)
}
}
</script>

View File

@@ -117,27 +117,9 @@ export default defineNuxtConfig({
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,png,svg,webp,ico,json,woff2}'], globPatterns: ['**/*.{js,css,html,png,svg,webp,ico,json,woff2}'],
navigateFallback: undefined, // Disable navigation fallback for SPA navigateFallback: undefined,
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, maximumFileSizeToCacheInBytes: 4 * 1024 * 1024
runtimeCaching: [
{
urlPattern: /^https:\/\/szesytydotpnuiuwybwb\.supabase\.co\//,
handler: 'NetworkFirst',
method: 'GET',
options: {
cacheName: 'supabase-data',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 60,
maxAgeSeconds: 3600
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}, },
client: { client: {
installPrompt: true, installPrompt: true,
@@ -160,11 +142,6 @@ export default defineNuxtConfig({
] ]
}, },
runtimeConfig: { runtimeConfig: {
supabase: {
url: process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL,
serviceRoleKey:
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
},
public: { public: {
authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com' authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com'
} }

View File

@@ -14,7 +14,6 @@
"@nuxt/test-utils": "^3.19.2", "@nuxt/test-utils": "^3.19.2",
"@nuxt/ui": "^4.0.0", "@nuxt/ui": "^4.0.0",
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
"@supabase/supabase-js": "^2.48.0",
"@vite-pwa/nuxt": "^0.9.1", "@vite-pwa/nuxt": "^0.9.1",
"nuxt": "^4.1.2", "nuxt": "^4.1.2",
"pinia": "^3.0.3", "pinia": "^3.0.3",

View File

@@ -1,22 +0,0 @@
import { parseQuerySegment } from '../../../services/query-parser'
import { fetchTableData, fetchTableRecord } from '../../../services/table-service'
export default defineEventHandler(async (event) => {
const table = event.context.params?.table
const segmentParam = event.context.params?.segment
if (!table || !segmentParam) {
throw createError({ statusCode: 400, statusMessage: 'Tabla o parámetro no especificados' })
}
const values = Array.isArray(segmentParam) ? segmentParam : [segmentParam]
const target = values[0]
const parsedQuery = parseQuerySegment(target)
if (parsedQuery) {
return await fetchTableData(table, { parsedQuery })
}
return await fetchTableRecord(table, target)
})

View File

@@ -1,30 +0,0 @@
import { parseQuerySegment } from '../../../services/query-parser'
import { fetchTableData } from '../../../services/table-service'
export default defineEventHandler(async (event) => {
const table = event.context.params?.table
if (!table) {
throw createError({ statusCode: 400, statusMessage: 'Tabla no especificada' })
}
const query = getQuery(event)
const limitValue = Number.parseInt((query.limit as string) ?? '', 10)
const limit = Number.isFinite(limitValue) ? Math.min(Math.max(limitValue, 1), 500) : undefined
const offsetValue = Number.parseInt((query.offset as string) ?? '', 10)
const offset = Number.isFinite(offsetValue) ? Math.max(offsetValue, 0) : undefined
const parsedQuery = parseQuerySegment(query.query as string | undefined)
return await fetchTableData(table, {
parsedQuery,
limit,
offset,
filters: {
id: (query.id as string) || undefined,
createdFrom: (query.created_from as string) || undefined,
createdTo: (query.created_to as string) || undefined
}
})
})

View File

@@ -1,9 +0,0 @@
import { fetchAllData } from '../../services/table-service'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const requestedLimit = Number.parseInt((query.limit as string) ?? '', 10)
const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 100) : 25
return await fetchAllData(limit)
})

View File

@@ -1,12 +0,0 @@
import { fetchTableRecordMetadata } from '../../../services/table-service'
export default defineEventHandler(async (event) => {
const table = event.context.params?.table
const id = event.context.params?.id
if (!table || !id) {
throw createError({ statusCode: 400, statusMessage: 'Tabla o id no especificados' })
}
return await fetchTableRecordMetadata(table, id)
})

View File

@@ -1,15 +0,0 @@
import { parseQuerySegment } from '../../../services/query-parser'
import { fetchTableMetadata } from '../../../services/table-service'
export default defineEventHandler(async (event) => {
const table = event.context.params?.table
if (!table) {
throw createError({ statusCode: 400, statusMessage: 'Tabla no especificada' })
}
const query = getQuery(event)
const parsedQuery = parseQuerySegment(query.query as string | undefined)
return await fetchTableMetadata(table, { parsedQuery })
})

View File

@@ -1,5 +0,0 @@
import { fetchAllTablesMetadata } from '../../services/table-service'
export default defineEventHandler(async () => {
return await fetchAllTablesMetadata()
})