Files
analiticaNucleo/nuxt4-app/app/pages/explorer.vue
2025-09-29 20:57:27 -06:00

337 lines
10 KiB
Vue

<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 Field Group -->
<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>
</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" 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">
{{ selectedTable.rowCount || 0 }} registros
</span>
</div>
</div>
</template>
<!-- Loading State for Table Data -->
<div v-if="tableDataLoading" 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>
<!-- NuxtUI Table -->
<div v-else-if="tableData.length > 0" 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="tableData"
: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)]">
No se encontraron datos en esta tabla.
</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 { useRequestFetch } from '#imports'
import { useMetadataStore } from '~/stores/metadata'
definePageMeta({
layout: 'dashboard',
title: 'Explorador de datos'
})
const UButton = resolveComponent('UButton')
// State
const selectedTableName = ref<string>('')
const tableData = ref<Record<string, unknown>[]>([])
const tableDataLoading = ref(false)
const globalFilter = ref('')
const requestFetch = useRequestFetch()
const table = useTemplateRef('table')
const metadataStore = useMetadataStore()
// 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 (!tableData.value.length) return []
const firstRow = tableData.value[0]
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 tableData.value.length
})
// Methods
async function selectTable(tableName: string) {
if (selectedTableName.value === tableName) return
selectedTableName.value = tableName
await loadTableData(tableName)
}
async function loadTableData(tableName: string) {
try {
tableDataLoading.value = true
const response = await requestFetch(`/api/data/${tableName}`, {
query: { limit: '100' }
})
if (response && typeof response === 'object' && 'records' in response) {
const dataset = response as {
records?: Record<string, unknown>[]
}
tableData.value = Array.isArray(dataset.records) ? dataset.records : []
} else if (Array.isArray(response)) {
tableData.value = response
} else {
tableData.value = []
}
} catch (error) {
console.error('Error loading table data:', error)
tableData.value = []
} finally {
tableDataLoading.value = false
}
}
function updateGlobalFilter(value: string) {
globalFilter.value = 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
}
// Watchers
watch(selectedTableName, async (newTableName) => {
if (newTableName) {
await loadTableData(newTableName)
} else {
tableData.value = []
}
})
// Auto-select first table when metadata becomes available
watch(() => metadataStore.hasMetadata, (hasMetadata) => {
if (hasMetadata && !selectedTableName.value && metadataStore.allTables.length > 0) {
selectedTableName.value = metadataStore.allTables[0].table
}
})
// Lifecycle
onMounted(async () => {
await metadataStore.initialize()
// Auto-select first table if available
if (metadataStore.hasMetadata && !selectedTableName.value) {
const firstTable = metadataStore.allTables[0]
if (firstTable) {
selectedTableName.value = firstTable.table
}
}
})
</script>