Files
analiticaNucleo/nuxt4-app/app/pages/explorer.vue
josedario87 d0b0dc3c56
All checks were successful
build-and-deploy / build (push) Successful in 43s
build-and-deploy / deploy (push) Successful in 3s
feat: add URL query params support to explorer page
- Add support for 'db' and 'table' query parameters
- Automatically preselect database and table from URL
- Auto-load data when both params are provided
- Update URL when user changes selection manually
- Example: /explorer?db=facturador+supabase&table=Ingresos
2025-10-13 19:52:00 -06:00

416 lines
12 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 visualice los datos de las tablas disponibles en Metabase.
</p>
</div>
</template>
<!-- Database and Table Selection -->
<div class="flex flex-col gap-4">
<!-- Database Selector -->
<UFieldGroup label="Base de datos">
<USelectMenu
v-model="selectedDatabase"
:items="databaseOptions"
placeholder="Seleccionar base de datos"
:loading="loadingDatabases"
:disabled="loadingDatabases"
/>
</UFieldGroup>
<!-- Table Selector -->
<UFieldGroup label="Tabla" v-if="selectedDatabase">
<USelectMenu
v-model="selectedTable"
:items="tableOptions"
placeholder="Seleccionar tabla"
:loading="loadingTables"
:disabled="loadingTables || !selectedDatabase"
/>
</UFieldGroup>
<!-- Load Data Button -->
<UButton
v-if="selectedTable"
:loading="loadingData"
:disabled="!selectedTable"
@click="loadTableData"
color="primary"
icon="i-lucide-refresh-cw"
>
Cargar datos
</UButton>
</div>
</UCard>
<!-- Loading State -->
<UCard v-if="loadingData" 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 datos...</span>
</div>
</UCard>
<!-- Error State -->
<UCard v-else-if="error" class="brand-card border border-transparent">
<div 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">
{{ error }}
</div>
<UButton
class="mt-4"
@click="loadTableData"
color="primary"
>
Reintentar
</UButton>
</div>
</UCard>
<!-- Table Data Display -->
<UCard v-else-if="tableData && tableData.length > 0" 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">
{{ selectedTableName }}
</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">
{{ tableData.length }} registros
</span>
</div>
</div>
</template>
<div class="flex-1 divide-y divide-accented w-full">
<!-- Search Input -->
<div class="flex items-center gap-2 px-4 py-3.5 overflow-x-auto">
<UInput
v-model="searchQuery"
class="max-w-sm min-w-[12ch]"
placeholder="Buscar en todos los campos..."
icon="i-lucide-search"
/>
<UButton
v-if="tableColumns.length > 0"
label="Columnas"
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
class="ml-auto"
@click="showColumnSelector = !showColumnSelector"
/>
</div>
<!-- Column Selector -->
<div v-if="showColumnSelector" class="px-4 py-3 bg-elevated border-b border-accented">
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<label
v-for="column in tableColumns"
:key="column.key"
class="flex items-center gap-2 text-sm cursor-pointer hover:text-[var(--brand-text)]"
>
<input
type="checkbox"
:checked="visibleColumns.includes(column.key)"
@change="toggleColumn(column.key)"
class="rounded"
/>
<span>{{ column.label }}</span>
</label>
</div>
</div>
<!-- Table -->
<UTable
:data="filteredData"
:columns="displayColumns"
:loading="loadingData"
class="h-96"
/>
<!-- Table Footer -->
<div class="px-4 py-3.5 text-sm text-muted">
{{ filteredData.length }} de {{ tableData.length }} filas mostradas.
</div>
</div>
</UCard>
<!-- Empty State -->
<UCard v-else-if="selectedTable" class="brand-card border border-transparent">
<div 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 "Cargar datos" para obtener la información de esta tabla.
</p>
</div>
</UCard>
<!-- No Selection State -->
<UCard v-else class="brand-card border border-transparent">
<div class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
Selecciona una base de datos y una tabla para explorar sus datos.
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
import { upperFirst } from 'scule'
definePageMeta({
layout: 'dashboard',
title: 'Explorador de datos'
})
// Get query params from URL
const route = useRoute()
const router = useRouter()
// State
const selectedDatabase = ref<any | null>(null)
const selectedTable = ref<any | null>(null)
const loadingDatabases = ref(false)
const loadingTables = ref(false)
const loadingData = ref(false)
const error = ref<string | null>(null)
const searchQuery = ref('')
const showColumnSelector = ref(false)
const databases = ref<any[]>([])
const tables = ref<any[]>([])
const tableData = ref<any[]>([])
const visibleColumns = ref<string[]>([])
// Track if we should auto-load data from URL params
const shouldAutoLoad = ref(false)
// Computed
const databaseOptions = computed(() => {
return databases.value.map(db => db.name)
})
const tableOptions = computed(() => {
return tables.value.map(table => table.display_name || table.name)
})
const selectedDatabaseId = computed(() => {
if (!selectedDatabase.value) return null
const db = databases.value.find(d => d.name === selectedDatabase.value)
return db?.id
})
const selectedTableId = computed(() => {
if (!selectedTable.value) return null
const table = tables.value.find(t => (t.display_name || t.name) === selectedTable.value)
return table?.id
})
const selectedTableName = computed(() => {
return selectedTable.value || ''
})
const tableColumns = computed(() => {
if (!tableData.value || tableData.value.length === 0) return []
const firstRow = tableData.value[0]
return Object.keys(firstRow).map(key => ({
key,
label: upperFirst(key)
}))
})
const displayColumns = computed(() => {
return tableColumns.value
.filter(col => visibleColumns.value.includes(col.key))
.map(col => ({
key: col.key,
label: col.label,
sortable: true
}))
})
const filteredData = computed(() => {
if (!searchQuery.value) return tableData.value
const query = searchQuery.value.toLowerCase()
return tableData.value.filter(row => {
return Object.values(row).some(value => {
if (value === null || value === undefined) return false
return String(value).toLowerCase().includes(query)
})
})
})
// Methods
async function loadDatabases() {
loadingDatabases.value = true
error.value = null
try {
const response = await $fetch('/api/metabase/databases')
databases.value = response as any[]
// Check if we have a database from URL params
const dbParam = route.query.db as string
if (dbParam && databases.value.length > 0) {
// Try to find database by name
const db = databases.value.find(d => d.name === dbParam)
if (db) {
selectedDatabase.value = db.name
shouldAutoLoad.value = true
} else {
// If not found, auto-select first database
selectedDatabase.value = databases.value[0].name
}
} else if (databases.value.length > 0) {
// Auto-select first database if no param
selectedDatabase.value = databases.value[0].name
}
} catch (err: any) {
error.value = `Error al cargar bases de datos: ${err.message || 'Error desconocido'}`
console.error('Error loading databases:', err)
} finally {
loadingDatabases.value = false
}
}
async function loadTables() {
if (!selectedDatabase.value || !selectedDatabaseId.value) return
loadingTables.value = true
error.value = null
selectedTable.value = null
tableData.value = []
try {
const response = await $fetch(`/api/metabase/tables/${selectedDatabaseId.value}`)
const metadata = response as any
// Metabase returns tables in the 'tables' property
tables.value = metadata.tables || []
// Check if we have a table from URL params
const tableParam = route.query.table as string
if (tableParam && tables.value.length > 0 && shouldAutoLoad.value) {
// Try to find table by name or display_name
const table = tables.value.find(t =>
(t.display_name || t.name) === tableParam ||
t.name === tableParam
)
if (table) {
selectedTable.value = table.display_name || table.name
// Load data automatically after table is selected
await nextTick()
await loadTableData()
}
// Reset auto-load flag
shouldAutoLoad.value = false
}
} catch (err: any) {
error.value = `Error al cargar tablas: ${err.message || 'Error desconocido'}`
console.error('Error loading tables:', err)
} finally {
loadingTables.value = false
}
}
async function loadTableData() {
if (!selectedDatabaseId.value || !selectedTableId.value) return
loadingData.value = true
error.value = null
try {
const response = await $fetch('/api/metabase/query', {
method: 'POST',
body: {
databaseId: selectedDatabaseId.value,
tableId: selectedTableId.value,
query: {
limit: 1000 // Limitar a 1000 registros por defecto
}
}
})
// Metabase returns data in a specific format
const result = response as any
if (result.data && result.data.rows && result.data.cols) {
// Transform Metabase response to array of objects
const columns = result.data.cols.map((col: any) => col.name)
tableData.value = result.data.rows.map((row: any[]) => {
const obj: any = {}
columns.forEach((col: string, index: number) => {
obj[col] = row[index]
})
return obj
})
// Initialize visible columns
visibleColumns.value = columns
} else {
tableData.value = []
}
} catch (err: any) {
error.value = `Error al cargar datos: ${err.message || 'Error desconocido'}`
console.error('Error loading table data:', err)
} finally {
loadingData.value = false
}
}
function toggleColumn(columnKey: string) {
const index = visibleColumns.value.indexOf(columnKey)
if (index > -1) {
visibleColumns.value.splice(index, 1)
} else {
visibleColumns.value.push(columnKey)
}
}
// Watchers
watch(selectedDatabase, (newDb) => {
loadTables()
// Update URL query params when database changes
if (newDb && !shouldAutoLoad.value) {
router.push({
query: {
...route.query,
db: newDb,
table: undefined // Clear table when db changes
}
})
}
})
watch(selectedTable, (newTable) => {
// Update URL query params when table changes
if (newTable && !shouldAutoLoad.value) {
router.push({
query: {
...route.query,
table: newTable
}
})
}
})
// Lifecycle
onMounted(() => {
loadDatabases()
})
</script>