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

327 lines
8.7 KiB
Vue

<template>
<div class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-10">
<UCard>
<template #header>
<div class="flex flex-col gap-2">
<h1 class="text-2xl font-semibold">Visor de datos</h1>
<p class="text-sm text-slate-300">
Selecciona una tabla e introduce filtros para consultar los datos en modo solo lectura.
</p>
</div>
</template>
<div class="grid gap-4 lg:grid-cols-4">
<UFormGroup label="Tabla" name="table">
<USelectMenu
v-model="filters.table"
:options="tableOptions"
value-attribute="value"
option-attribute="label"
placeholder="Selecciona una tabla"
/>
</UFormGroup>
<UFormGroup label="ID" name="id">
<UInput v-model="filters.id" placeholder="Filtrar por ID" />
</UFormGroup>
<UFormGroup label="Fecha desde" name="from">
<UInput v-model="filters.from" type="date" />
</UFormGroup>
<UFormGroup label="Fecha hasta" name="to">
<UInput v-model="filters.to" type="date" />
</UFormGroup>
</div>
<template #footer>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs text-slate-400">
Para filtros avanzados puedes codificar un objeto JSON en base64 e invocarlo mediante la ruta
<code class="rounded bg-slate-800 px-2 py-1">/api/data/{{ filters.table }}/[query]</code>.
</div>
<div class="flex gap-2">
<UButton color="gray" variant="soft" @click="resetFilters" :disabled="loading">
Limpiar filtros
</UButton>
<UButton @click="refresh" :loading="loading" :disabled="!filters.table">
Consultar datos
</UButton>
</div>
</div>
</template>
</UCard>
<div v-if="errorMessage" class="rounded-lg border border-red-500 bg-red-500/10 p-4 text-sm text-red-200">
{{ errorMessage }}
</div>
<div v-if="activeMetadata" class="grid gap-4 lg:grid-cols-2">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Resumen de {{ activeMetadata.table }}</h2>
<UBadge color="primary">{{ activeMetadata.rowCount }} registros</UBadge>
</div>
</template>
<dl class="grid grid-cols-2 gap-2 text-sm">
<div>
<dt class="text-slate-400">Clave primaria</dt>
<dd class="font-medium">{{ activeMetadata.primaryKey }}</dd>
</div>
<div>
<dt class="text-slate-400">Última consulta</dt>
<dd class="font-medium">{{ formatDate(activeMetadata.lastRefreshed) }}</dd>
</div>
<div>
<dt class="text-slate-400">Tamaño aprox.</dt>
<dd class="font-medium">{{ formatSize(activeMetadata.approxSizeBytes) }}</dd>
</div>
<div>
<dt class="text-slate-400">Rango de creación</dt>
<dd class="font-medium">
{{ formatDate(activeMetadata.createdAtRange?.from) }}
{{ formatDate(activeMetadata.createdAtRange?.to) }}
</dd>
</div>
</dl>
<template #footer>
<div class="text-xs text-slate-400">
Columnas detectadas: {{ activeMetadata.columns.join(', ') }}
</div>
</template>
</UCard>
<UCard v-if="activeMetadata.sampleRow">
<template #header>
<h2 class="text-lg font-semibold">Registro de ejemplo</h2>
</template>
<pre class="overflow-auto rounded bg-slate-900 p-4 text-sm">{{ formatSample(activeMetadata.sampleRow) }}</pre>
</UCard>
</div>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-2">
<h2 class="text-lg font-semibold">Datos</h2>
<UBadge v-if="tableData" color="gray">
{{ tableData.length }} registros visibles
</UBadge>
</div>
</template>
<div v-if="loading" class="flex items-center justify-center py-10">
<ULoadingIndicator size="lg" />
</div>
<div v-else-if="tableData.length === 0" class="py-10 text-center text-sm text-slate-400">
No hay datos que coincidan con los filtros actuales.
</div>
<div v-else class="overflow-auto">
<table class="min-w-full divide-y divide-slate-800 text-sm">
<thead class="bg-slate-900/60">
<tr>
<th v-for="column in visibleColumns" :key="column" class="px-4 py-2 text-left font-semibold">
{{ column }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
<tr v-for="(row, index) in tableData" :key="index" class="hover:bg-slate-900/40">
<td v-for="column in visibleColumns" :key="column" class="px-4 py-2">
{{ formatCell(row[column]) }}
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
const loading = ref(false)
const errorMessage = ref<string | null>(null)
const tableData = ref<Record<string, unknown>[]>([])
const activeMetadata = ref<any | null>(null)
const availableMetadata = ref<any[]>([])
const filters = reactive({
table: '',
id: '',
from: '',
to: ''
})
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]) : []))
onMounted(async () => {
await loadAvailableMetadata()
})
watch(
() => filters.table,
async (value) => {
if (!value) {
tableData.value = []
activeMetadata.value = null
return
}
await refresh()
}
)
async function loadAvailableMetadata() {
try {
const metadata = await $fetch('/api/metadata')
availableMetadata.value = Array.isArray(metadata) ? metadata : []
} catch (error) {
errorMessage.value = extractErrorMessage(error)
}
}
async function refresh() {
if (!filters.table) {
return
}
loading.value = true
errorMessage.value = null
try {
const [metadata, data] = await Promise.all([
$fetch(`/api/metadata/${filters.table}`),
$fetch(`/api/data/${filters.table}`, {
query: buildQueryParams()
})
])
activeMetadata.value = metadata
const metadataIndex = availableMetadata.value.findIndex((item) => item.table === metadata.table)
if (metadataIndex >= 0) {
availableMetadata.value[metadataIndex] = metadata
}
tableData.value = data?.records ?? []
} catch (error) {
errorMessage.value = extractErrorMessage(error)
tableData.value = []
} finally {
loading.value = false
}
}
function buildQueryParams() {
const params: Record<string, string> = { limit: '100' }
if (filters.id) {
params.id = filters.id.trim()
}
if (filters.from) {
params.created_from = filters.from
}
if (filters.to) {
params.created_to = filters.to
}
return params
}
function resetFilters() {
filters.id = ''
filters.from = ''
filters.to = ''
if (filters.table) {
refresh()
}
}
function extractErrorMessage(error: unknown) {
if (error && typeof error === 'object' && 'statusMessage' in error) {
return String((error as any).statusMessage)
}
if (error instanceof Error) {
return error.message
}
return 'Ocurrió un error inesperado al consultar los datos.'
}
function formatSize(bytes: number | null) {
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>