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
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',
to: '/',
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" />
</template>
<template #trailing>
<UBadge variant="subtle" label="Supabase" class="uppercase tracking-wide" />
<UBadge variant="subtle" label="Solo lectura" class="uppercase tracking-wide" />
<AuthSessionStatusButton />
<UserMenu />
</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
</h1>
<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>
</div>
</div>
<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="inline-block h-2 w-2 rounded-full bg-[#ffe0a0]"></span>
Solo lectura
</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 class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
Activo
</span>
</div>
</div>
</template>
<div class="grid gap-6 md:grid-cols-2">
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-3">
<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 class="text-center py-12 space-y-6">
<div class="flex justify-center">
<div class="w-24 h-24 rounded-full bg-gradient-to-br from-[#c08040]/20 to-[#ffe0a0]/20 flex items-center justify-center">
<UIcon name="i-lucide-chart-line" class="size-12 text-[#ffe0a0]" />
</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>
</UCard>
<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">
<template #header>
<div class="flex items-center gap-2">
@@ -95,43 +58,46 @@
</div>
</template>
<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
ni se almacenan datos sensibles en el cliente.
Acceso protegido mediante Authentik con autenticación de doble factor y gestión de permisos basada en grupos.
</p>
</UCard>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-life-buoy" class="size-5 text-[#ffe0a0]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">¿Necesitas ayuda?</span>
<UIcon name="i-lucide-user" class="size-5 text-[#ffe0a0]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">Mi Perfil</span>
</div>
</template>
<p class="text-sm text-[var(--brand-text-muted)]">
Consulta la documentación interna o abre un ticket de soporte para integrar nuevas fuentes, automatizar
reportes o resolver incidentes.
<p class="text-sm text-[var(--brand-text-muted)] mb-3">
Gestiona tu información personal y preferencias del sistema.
</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>
</section>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
definePageMeta({
layout: 'dashboard',
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>

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: {
globPatterns: ['**/*.{js,css,html,png,svg,webp,ico,json,woff2}'],
navigateFallback: undefined, // Disable navigation fallback for SPA
navigateFallback: undefined,
cleanupOutdatedCaches: true,
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]
}
}
}
]
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024
},
client: {
installPrompt: true,
@@ -160,11 +142,6 @@ export default defineNuxtConfig({
]
},
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: {
authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com'
}

View File

@@ -14,7 +14,6 @@
"@nuxt/test-utils": "^3.19.2",
"@nuxt/ui": "^4.0.0",
"@pinia/nuxt": "^0.11.2",
"@supabase/supabase-js": "^2.48.0",
"@vite-pwa/nuxt": "^0.9.1",
"nuxt": "^4.1.2",
"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()
})