UI layout establecido
This commit is contained in:
145
nuxt4-app/app/layouts/dashboard.vue
Normal file
145
nuxt4-app/app/layouts/dashboard.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div class="brand-shell min-h-screen text-[#fef9f0]">
|
||||||
|
<UDashboardGroup storage-key="analytics-dashboard" class="h-full">
|
||||||
|
<UDashboardSidebar
|
||||||
|
v-model:open="sidebarOpen"
|
||||||
|
v-model:collapsed="sidebarCollapsed"
|
||||||
|
collapsible
|
||||||
|
resizable
|
||||||
|
:default-size="28"
|
||||||
|
:min-size="20"
|
||||||
|
:max-size="38"
|
||||||
|
:toggle="{ color: 'primary', variant: 'subtle', class: 'rounded-full' }"
|
||||||
|
>
|
||||||
|
<template #header="{ collapsed }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
v-if="!collapsed"
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Analítica Núcleo"
|
||||||
|
class="h-8 w-8 rounded-full border border-[#ffe0a0]/40"
|
||||||
|
/>
|
||||||
|
<UIcon v-else name="i-lucide-activity" class="size-5 text-[#ffe0a0]" />
|
||||||
|
<span v-if="!collapsed" class="text-sm font-semibold text-[var(--brand-text)]">Analítica Núcleo</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="{ collapsed }">
|
||||||
|
<UButton
|
||||||
|
:label="collapsed ? undefined : 'Buscar...'"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
block
|
||||||
|
:square="collapsed"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template v-if="!collapsed" #trailing>
|
||||||
|
<div class="flex items-center gap-0.5 ms-auto text-[var(--brand-text-muted)]">
|
||||||
|
<UKbd value="⌘" variant="subtle" />
|
||||||
|
<UKbd value="K" variant="subtle" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UNavigationMenu
|
||||||
|
:collapsed="collapsed"
|
||||||
|
:items="navigationPrimary"
|
||||||
|
orientation="vertical"
|
||||||
|
class="gap-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UNavigationMenu
|
||||||
|
:collapsed="collapsed"
|
||||||
|
:items="navigationSecondary"
|
||||||
|
orientation="vertical"
|
||||||
|
class="mt-auto gap-1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ collapsed }">
|
||||||
|
<UButton
|
||||||
|
:avatar="{ src: 'https://avatars.githubusercontent.com/u/12011070?v=4' }"
|
||||||
|
:label="collapsed ? undefined : 'Equipo Núcleo'"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
:block="collapsed"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<UIcon name="i-lucide-log-out" class="size-4" />
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UDashboardSidebar>
|
||||||
|
|
||||||
|
<UDashboardPanel class="bg-transparent">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-col gap-4 px-4 py-4 lg:px-6">
|
||||||
|
<UDashboardNavbar :title="pageTitle" icon="i-lucide-pie-chart" toggle-side="left">
|
||||||
|
<template #leading>
|
||||||
|
<UDashboardSidebarCollapse variant="subtle" />
|
||||||
|
</template>
|
||||||
|
<template #toggle>
|
||||||
|
<UDashboardSidebarToggle variant="subtle" />
|
||||||
|
</template>
|
||||||
|
<template #trailing>
|
||||||
|
<UBadge variant="subtle" label="Supabase" class="uppercase tracking-wide" />
|
||||||
|
<UBadge variant="subtle" label="Solo lectura" class="uppercase tracking-wide" />
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="px-4 pb-10 lg:px-8">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDashboardPanel>
|
||||||
|
</UDashboardGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const sidebarOpen = ref(true)
|
||||||
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
||||||
|
const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: 'Inicio',
|
||||||
|
icon: 'i-lucide-home',
|
||||||
|
to: '/',
|
||||||
|
active: route.path === '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Explorador de datos',
|
||||||
|
icon: 'i-lucide-table',
|
||||||
|
to: '/explorer',
|
||||||
|
active: route.path === '/explorer'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const navigationSecondary: NavigationMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Documentación',
|
||||||
|
icon: 'i-lucide-book-open',
|
||||||
|
to: 'https://ui.nuxt.com',
|
||||||
|
target: '_blank'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Repositorio',
|
||||||
|
icon: 'i-lucide-github',
|
||||||
|
to: 'https://gitea.nucleoriofrio.com/nucleo000/analiticaNucleo',
|
||||||
|
target: '_blank'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const pageTitle = computed(() => (route.meta.title as string) || 'Panel')
|
||||||
|
</script>
|
||||||
795
nuxt4-app/app/pages/explorer.vue
Normal file
795
nuxt4-app/app/pages/explorer.vue
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
<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>
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto flex max-w-7xl flex-col gap-10 px-4 py-14 text-[#fef9f0]">
|
<div class="flex flex-col gap-8">
|
||||||
<UCard class="brand-card border border-transparent backdrop-blur-sm">
|
<UCard class="brand-card border border-transparent backdrop-blur-sm">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="flex items-center gap-5">
|
<div class="flex items-center gap-5">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="Analítica Nucleo"
|
alt="Analítica Núcleo"
|
||||||
class="h-16 w-16 rounded-full border border-[#ffe0a0]/40 shadow-lg shadow-[#c08040]/25"
|
class="h-16 w-16 rounded-full border border-[#ffe0a0]/40 shadow-lg shadow-[#c08040]/25"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h1 class="text-3xl font-semibold tracking-tight text-[var(--brand-text)]">
|
<h1 class="text-3xl font-semibold tracking-tight text-[var(--brand-text)]">
|
||||||
Analítica Nucleo 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)]">
|
||||||
Explora y valida tus tablas Supabase desde un único panel en modo lectura.
|
Bienvenido al panel principal. Selecciona una sección para comenzar a explorar tus datos Supabase.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,795 +30,108 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<form class="flex flex-col gap-6" @submit.prevent="executeRequest">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<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">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-lg font-semibold brand-section-title">Resumen de {{ activeMetadata.table }}</h2>
|
<UIcon name="i-lucide-table" class="size-6 text-[#ffe0a0]" />
|
||||||
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
|
<div>
|
||||||
{{ activeMetadata.rowCount }} registros
|
<h3 class="text-lg font-semibold text-[var(--brand-text)]">Explorador de datos</h3>
|
||||||
</span>
|
<p class="text-sm text-[var(--brand-text-muted)]">Consulta tablas, metadatos y registros filtrados.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template #footer>
|
||||||
<div class="brand-divider pt-3 text-xs text-[var(--brand-text-muted)]">
|
<NuxtLink to="/explorer" class="text-sm font-semibold text-[#ffe0a0] hover:underline">
|
||||||
Columnas detectadas: {{ activeMetadata.columns.join(', ') }}
|
Ir al explorador →
|
||||||
</div>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<UCard v-if="activeMetadata.sampleRow" class="brand-card border border-transparent">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-lg font-semibold brand-section-title">Registro de ejemplo</h2>
|
<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>
|
</template>
|
||||||
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
{{ formatSample(activeMetadata.sampleRow) }}
|
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
|
||||||
</pre>
|
<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>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard v-if="metadataRecord" class="brand-card border border-transparent">
|
<section class="grid gap-6 md:grid-cols-3">
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-lg font-semibold brand-section-title">Metadata del registro {{ metadataRecord.id }}</h2>
|
<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>
|
</template>
|
||||||
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
|
<ol class="list-decimal space-y-2 pl-4 text-sm text-[var(--brand-text-muted)]">
|
||||||
{{ formatSample(metadataRecord.metadata) }}
|
<li>Abre “Explorador de datos” desde la barra lateral.</li>
|
||||||
</pre>
|
<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">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-shield-check" class="size-5 text-[#ffe0a0]" />
|
||||||
|
<span class="text-sm font-semibold text-[var(--brand-text)]">Seguridad</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<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
|
||||||
|
ni se almacenan datos sensibles en el cliente.
|
||||||
|
</p>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-life-buoy" class="size-5 text-[#ffe0a0]" />
|
||||||
|
<span class="text-sm font-semibold text-[var(--brand-text)]">¿Necesitas ayuda?</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="text-sm text-[var(--brand-text-muted)]">
|
||||||
|
Consulta la documentación interna o abre un ticket de soporte para integrar nuevas fuentes, automatizar
|
||||||
|
reportes o resolver incidentes.
|
||||||
|
</p>
|
||||||
</UCard>
|
</UCard>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRequestFetch } from '#imports'
|
|
||||||
|
|
||||||
type RequestType = 'data' | 'metadata'
|
definePageMeta({
|
||||||
type MetadataScope = 'all' | 'table' | 'record'
|
layout: 'dashboard',
|
||||||
type DataScope = 'all' | 'table' | 'record' | 'query'
|
title: 'Inicio'
|
||||||
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(() => {
|
|
||||||
if (request.type === 'metadata') {
|
|
||||||
return request.scope !== 'all'
|
|
||||||
}
|
|
||||||
|
|
||||||
return request.scope !== 'all'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiresRecordId = computed(() => request.scope === 'record')
|
const availableMetadata = useState('availableMetadataSummary', () => [])
|
||||||
|
const lastUpdated = useState<string>('dashboardLastUpdated', () => new Date().toISOString())
|
||||||
|
|
||||||
const showsLimit = computed(
|
const metadataCount = computed(() => availableMetadata.value.length || '—')
|
||||||
() => request.type === 'data' && (request.scope === 'all' || request.scope === 'table' || request.scope === 'query')
|
|
||||||
)
|
|
||||||
|
|
||||||
const showsDateFilters = computed(() => request.type === 'data' && request.scope === 'table')
|
const lastUpdatedText = computed(() => {
|
||||||
const showsIdFilter = computed(() => request.type === 'data' && request.scope === 'table')
|
if (!lastUpdated.value) return 'Sin registros'
|
||||||
const showQueryJson = computed(() => request.type === 'data' && request.scope === 'query')
|
const date = new Date(lastUpdated.value)
|
||||||
|
return Number.isNaN(date.getTime()) ? lastUpdated.value : date.toLocaleString()
|
||||||
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()
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const limit = sanitizeLimit(request.limit)
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const defaultTable = availableMetadata.value[0]?.table
|
|
||||||
if (defaultTable) {
|
|
||||||
request.table = defaultTable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
if (!request.table && requiresTable.value) {
|
|
||||||
const firstTable = metadata[0]?.table
|
|
||||||
if (firstTable) {
|
|
||||||
request.table = firstTable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore Buffer is available en entornos node
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consultas de datos
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consulta avanzada
|
|
||||||
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
|
|
||||||
} 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>
|
</script>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 102 KiB |
Reference in New Issue
Block a user