Files
analiticaNucleo/nuxt4-app/app/pages/explorer.vue

382 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
<!-- Refresh Controls (only shown when table is selected) -->
<div v-if="selectedTable && currentTableStore" class="flex items-center justify-between p-3 rounded-lg bg-[#1c140c] border border-[#3a2a16]">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-[var(--brand-text-muted)]">
Última actualización: {{ currentTableStore.formattedLastUpdated }}
</span>
<span v-if="currentTableStore.isStale" class="text-xs text-yellow-400">
Los datos pueden estar desactualizados
</span>
</div>
<UButton
:loading="currentTableStore.isLoading"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
size="sm"
@click="refreshTableData"
>
<template #leading>
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': currentTableStore.isLoading }" />
</template>
Actualizar datos
</UButton>
</div>
</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 {
name: metadata.table,
label: `${metadata.table} (${metadata.rowCount || 0} registros)`,
rowCount: metadata.rowCount || 0,
primaryKey: metadata.primaryKey,
columns: metadata.columns || [],
...metadata
}
})
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.table)
}))
})
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
function selectTable(tableName: string) {
if (selectedTableName.value === tableName) return
selectedTableName.value = tableName
// Get the table store using the plugin
if (typeof $getTableStore === 'function') {
const store = $getTableStore(tableName)
if (store) {
currentTableStore.value = store
// Initialize the store (loads from cache or fetches)
store.initialize()
}
} else {
// Fallback: create store directly
currentTableStore.value = useTableDataStore(tableName)
currentTableStore.value.initialize()
}
}
async function refreshTableData() {
if (currentTableStore.value) {
await currentTableStore.value.refreshData()
}
}
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 () => {
await metadataStore.initialize()
// Auto-select first table if available
if (metadataStore.hasMetadata && !selectedTableName.value) {
const firstTable = metadataStore.allTables[0]
if (firstTable) {
selectTable(firstTable.table)
}
}
})
// Auto-select first table when metadata becomes available
watch(() => metadataStore.hasMetadata, (hasMetadata) => {
if (hasMetadata && !selectedTableName.value && metadataStore.allTables.length > 0) {
selectTable(metadataStore.allTables[0].table)
}
})
</script>