Files
analiticaNucleo/nuxt4-app/app/components/metabase/MetabaseCardsTable.vue
josedario87 f8ae9e44e7
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 47s
Fix: aplicar estilos de marca a tabs y input de búsqueda en Metabase Debug
- Envolver tabs en UCard con clase brand-card
- Aplicar estilos de variables CSS al input de búsqueda
- Configurar focus ring con color de marca #c08040
- Usar colores de texto y fondo del tema en input
2025-10-29 18:11:43 -06:00

391 lines
12 KiB
Vue

<template>
<div class="space-y-4">
<!-- Filters and Search -->
<div class="flex flex-col sm:flex-row gap-3">
<UInput
v-model="search"
placeholder="Buscar por nombre o ID..."
icon="i-heroicons-magnifying-glass"
class="flex-1"
:ui="{
base: 'relative block w-full disabled:cursor-not-allowed disabled:opacity-75 focus:outline-none border-0',
form: 'form-input',
rounded: 'rounded-md',
placeholder: 'placeholder-[var(--brand-text-muted)]',
size: {
'2xs': 'text-xs',
xs: 'text-xs',
sm: 'text-sm',
md: 'text-sm',
lg: 'text-sm',
xl: 'text-base'
},
gap: {
'2xs': 'gap-x-1',
xs: 'gap-x-1.5',
sm: 'gap-x-1.5',
md: 'gap-x-2',
lg: 'gap-x-2.5',
xl: 'gap-x-2.5'
},
padding: {
'2xs': 'px-2 py-1',
xs: 'px-2.5 py-1.5',
sm: 'px-2.5 py-1.5',
md: 'px-3 py-2',
lg: 'px-3.5 py-2.5',
xl: 'px-3.5 py-2.5'
},
leading: {
padding: {
'2xs': 'ps-7',
xs: 'ps-8',
sm: 'ps-9',
md: 'ps-10',
lg: 'ps-11',
xl: 'ps-12'
}
},
trailing: {
padding: {
'2xs': 'pe-7',
xs: 'pe-8',
sm: 'pe-9',
md: 'pe-10',
lg: 'pe-11',
xl: 'pe-12'
}
},
color: {
white: {
outline: 'shadow-sm bg-[var(--brand-bg)] text-[var(--brand-text)] ring-1 ring-inset ring-[var(--brand-border)] focus:ring-2 focus:ring-[#c08040]'
},
gray: {
outline: 'shadow-sm bg-[var(--brand-bg)] text-[var(--brand-text)] ring-1 ring-inset ring-[var(--brand-border)] focus:ring-2 focus:ring-[#c08040]'
}
},
variant: {
outline: 'shadow-sm bg-[var(--brand-bg)] text-[var(--brand-text)] ring-1 ring-inset ring-[var(--brand-border)] focus:ring-2 focus:ring-[#c08040]',
none: 'bg-transparent focus:ring-0 focus:shadow-none'
},
icon: {
base: 'flex-shrink-0 text-[var(--brand-text-muted)]',
color: 'text-{color}-400 dark:text-{color}-500',
loading: 'animate-spin',
size: {
'2xs': 'h-4 w-4',
xs: 'h-4 w-4',
sm: 'h-5 w-5',
md: 'h-5 w-5',
lg: 'h-5 w-5',
xl: 'h-6 w-6'
},
leading: {
wrapper: 'absolute inset-y-0 start-0 flex items-center',
pointer: 'pointer-events-none',
padding: {
'2xs': 'px-2',
xs: 'px-2.5',
sm: 'px-2.5',
md: 'px-3',
lg: 'px-3.5',
xl: 'px-3.5'
}
},
trailing: {
wrapper: 'absolute inset-y-0 end-0 flex items-center',
pointer: 'pointer-events-none',
padding: {
'2xs': 'px-2',
xs: 'px-2.5',
sm: 'px-2.5',
md: 'px-3',
lg: 'px-3.5',
xl: 'px-3.5'
}
}
},
default: {
size: 'sm',
color: 'white',
variant: 'outline',
loadingIcon: 'i-heroicons-arrow-path-20-solid'
}
}"
/>
<USelectMenu
v-model="selectedFilter"
:options="filterOptions"
placeholder="Filtrar..."
class="w-full sm:w-auto"
/>
</div>
<!-- Table -->
<UTable
:rows="filteredCards"
:columns="columns"
:loading="loading"
@select="selectCard"
>
<template #id-data="{ row }">
<UBadge color="gray" variant="subtle">{{ row.id }}</UBadge>
</template>
<template #name-data="{ row }">
<div>
<div class="font-medium text-[var(--brand-text)]">{{ row.name }}</div>
<div v-if="row.description" class="text-xs text-[var(--brand-text-muted)] truncate max-w-md">
{{ row.description }}
</div>
</div>
</template>
<template #query_type-data="{ row }">
<UBadge :color="getQueryTypeColor(row.query_type || row.dataset_query?.type)">
{{ row.query_type || row.dataset_query?.type || 'N/A' }}
</UBadge>
</template>
<template #database_id-data="{ row }">
<span class="text-sm text-[var(--brand-text)]">{{ row.database_id || 'N/A' }}</span>
</template>
<template #actions-data="{ row }">
<div class="flex flex-wrap gap-1">
<UButton
@click.stop="executeCard(row)"
:loading="executingCards.has(row.id)"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c] disabled:opacity-50 disabled:cursor-not-allowed' }"
size="xs"
>
Ejecutar
</UButton>
<UButton
@click.stop="executeCachedCard(row)"
:loading="executingCachedCards.has(row.id)"
color="gray"
variant="ghost"
size="xs"
>
Caché
</UButton>
<UDropdown :items="getExportItems(row)">
<UButton
color="gray"
variant="ghost"
size="xs"
icon="i-heroicons-arrow-down-tray"
/>
</UDropdown>
</div>
</template>
</UTable>
<!-- Results Modal -->
<UModal v-model="showResults">
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold brand-section-title">Resultados: {{ selectedCardForResults?.name }}</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark"
@click="showResults = false"
/>
</div>
</template>
<div v-if="currentResult" class="space-y-3">
<div class="flex flex-wrap gap-4 text-sm text-[var(--brand-text)]">
<div>
<span class="font-medium text-[var(--brand-text-muted)]">Filas:</span>
<span class="ml-2">{{ currentResult.data?.rows?.length || 0 }}</span>
</div>
<div>
<span class="font-medium text-[var(--brand-text-muted)]">Tiempo:</span>
<span class="ml-2">{{ currentResult.running_time || 0 }}ms</span>
</div>
<div>
<span class="font-medium text-[var(--brand-text-muted)]">Estado:</span>
<UBadge :color="currentResult.status === 'completed' ? 'green' : 'yellow'">
{{ currentResult.status }}
</UBadge>
</div>
</div>
<!-- Empty State -->
<div v-if="!currentResult.data?.rows || currentResult.data.rows.length === 0" class="p-8 text-center rounded-lg border border-[#3a2a16] bg-[#1c140c]">
<div class="mx-auto w-16 h-16 mb-4 flex items-center justify-center bg-[#3a2a16] rounded-full">
<svg class="w-8 h-8 text-[var(--brand-text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
<h3 class="text-sm font-medium text-[var(--brand-text)] mb-1">No hay datos</h3>
<p class="text-xs text-[var(--brand-text-muted)]">La consulta se ejecutó correctamente pero no devolvió resultados.</p>
</div>
<!-- Data Display -->
<div v-else class="overflow-x-auto max-h-96">
<pre class="p-3 rounded-lg border border-[#3a2a16] bg-[#1c140c] text-xs whitespace-pre-wrap break-words text-[var(--brand-text)]">{{ JSON.stringify(currentResult.data, null, 2) }}</pre>
</div>
</div>
<UAlert
v-if="currentError"
color="red"
variant="soft"
:title="currentError"
class="mt-3"
/>
</UCard>
</UModal>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
cards: any[]
loading?: boolean
}>()
const emit = defineEmits<{
select: [card: any]
}>()
const search = ref('')
const selectedFilter = ref('all')
const executingCards = ref(new Set<number>())
const executingCachedCards = ref(new Set<number>())
const showResults = ref(false)
const currentResult = ref<any>(null)
const currentError = ref<string | null>(null)
const selectedCardForResults = ref<any>(null)
const filterOptions = [
{ value: 'all', label: 'Todas' },
{ value: 'native', label: 'SQL Nativo' },
{ value: 'query', label: 'Query Builder' }
]
const columns = [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Nombre' },
{ key: 'query_type', label: 'Tipo' },
{ key: 'database_id', label: 'DB' },
{ key: 'actions', label: 'Acciones' }
]
const filteredCards = computed(() => {
let result = props.cards
// Filter by search
if (search.value) {
const searchLower = search.value.toLowerCase()
result = result.filter(card =>
card.name?.toLowerCase().includes(searchLower) ||
card.id?.toString().includes(searchLower) ||
card.description?.toLowerCase().includes(searchLower)
)
}
// Filter by type
if (selectedFilter.value !== 'all') {
result = result.filter(card => {
const type = card.query_type || card.dataset_query?.type
return type === selectedFilter.value
})
}
return result
})
function getQueryTypeColor(type: string) {
switch (type) {
case 'native':
return 'blue'
case 'query':
return 'green'
default:
return 'gray'
}
}
function selectCard(card: any) {
emit('select', card)
}
async function executeCard(card: any) {
executingCards.value.add(card.id)
currentError.value = null
currentResult.value = null
selectedCardForResults.value = card
try {
const result = await $fetch(`/api/metabase/cards/${card.id}/query`, {
method: 'POST',
body: {
parameters: [
{ type: 'category', target: ['variable', ['template-tag', 'incluir_anulados']], value: [false] },
{ type: 'date/single', target: ['variable', ['template-tag', 'fecha_desde']], value: null },
{ type: 'date/single', target: ['variable', ['template-tag', 'fecha_hasta']], value: null }
]
}
})
currentResult.value = result
showResults.value = true
} catch (e: any) {
currentError.value = e.message || 'Error al ejecutar la query'
showResults.value = true
} finally {
executingCards.value.delete(card.id)
}
}
async function executeCachedCard(card: any) {
executingCachedCards.value.add(card.id)
currentError.value = null
currentResult.value = null
selectedCardForResults.value = card
try {
const result = await $fetch(`/api/metabase/cards/${card.id}/query`)
currentResult.value = result
showResults.value = true
} catch (e: any) {
currentError.value = e.message || 'Error al ejecutar la query con caché'
showResults.value = true
} finally {
executingCachedCards.value.delete(card.id)
}
}
function getExportItems(card: any) {
return [[
{
label: 'CSV',
icon: 'i-heroicons-document-text',
click: () => downloadExport(card, 'csv')
},
{
label: 'JSON',
icon: 'i-heroicons-code-bracket',
click: () => downloadExport(card, 'json')
},
{
label: 'XLSX',
icon: 'i-heroicons-table-cells',
click: () => downloadExport(card, 'xlsx')
}
]]
}
function downloadExport(card: any, format: 'csv' | 'json' | 'xlsx') {
const url = `${window.location.origin}/api/metabase/cards/${card.id}/query/${format}`
window.open(url, '_blank')
}
</script>