Remove all database dependencies and simplify application
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:
@@ -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
|
||||||
|
|||||||
BIN
Untitled.png
BIN
Untitled.png
Binary file not shown.
|
Before Width: | Height: | Size: 440 KiB |
@@ -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'
|
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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 })
|
|
||||||
})
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { fetchAllTablesMetadata } from '../../services/table-service'
|
|
||||||
|
|
||||||
export default defineEventHandler(async () => {
|
|
||||||
return await fetchAllTablesMetadata()
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user