Files
analiticaNucleo/nuxt4-app/app/components/metabase/MetabaseCardDisplay.vue
josedario87 76eaa5fd6a
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 47s
Fix: corregir tipos de parámetros en queries de Metabase
Problema:
- Las queries de Metabase fallaban con error 500 al ejecutarse desde metabase-debug
- Metabase rechazaba los parámetros porque usaban tipos simples en lugar de tipos con operadores

Solución:
- Actualizar mapeo de tipos de parámetros en MetabaseCardDisplay.vue
- boolean → boolean/=
- date → date/single
- number → number/=
- text → string/=

Esto corrige el error "Tipo de parámetro no válido :text para el parámetro 'cliente_ids'"
2025-10-29 17:30:12 -06:00

419 lines
14 KiB
Vue

<template>
<UCard>
<template #header>
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-semibold">{{ card.name }}</h3>
<p v-if="card.description" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ card.description }}
</p>
</div>
<UBadge :color="getStatusColor(card)">
ID: {{ card.id }}
</UBadge>
</div>
</template>
<!-- Card Details -->
<div class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<span class="font-medium">Database ID:</span>
<span class="ml-2">{{ card.database_id }}</span>
</div>
<div>
<span class="font-medium">Query Type:</span>
<span class="ml-2">{{ card.query_type || card.dataset_query?.type }}</span>
</div>
<div>
<span class="font-medium">Collection:</span>
<span class="ml-2">{{ card.collection_id || 'Root' }}</span>
</div>
<div>
<span class="font-medium">Created:</span>
<span class="ml-2">{{ formatDate(card.created_at) }}</span>
</div>
</div>
<!-- Query Preview -->
<div v-if="card.dataset_query?.native?.query" class="mt-4">
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-primary">
Ver SQL
</summary>
<pre class="mt-2 p-3 bg-gray-50 dark:bg-gray-900 rounded text-xs overflow-x-auto whitespace-pre-wrap break-words">{{ card.dataset_query.native.query }}</pre>
</details>
</div>
<!-- Parameters Form -->
<div v-if="hasParameters" class="mt-4">
<details class="group" :open="showParameters">
<summary class="cursor-pointer text-sm font-medium text-primary flex items-center gap-2 py-2">
<UIcon name="i-heroicons-adjustments-horizontal" class="w-4 h-4" />
<span>Configurar Parámetros</span>
<UIcon name="i-heroicons-chevron-down-20-solid" class="w-4 h-4 transition-transform group-open:rotate-180" />
</summary>
<div class="mt-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-3">
<div v-for="(param, key) in parameterValues" :key="key" class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ getParameterLabel(key) }}
</label>
<!-- Boolean parameter -->
<UToggle
v-if="getParameterType(key) === 'boolean'"
v-model="parameterValues[key]"
size="sm"
/>
<!-- Date parameter -->
<input
v-else-if="getParameterType(key) === 'date'"
v-model="parameterValues[key]"
type="date"
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
<!-- Text parameter -->
<input
v-else
v-model="parameterValues[key]"
type="text"
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
</div>
<div class="flex gap-2 pt-2">
<UButton
@click="resetParameters"
color="gray"
variant="soft"
size="xs"
icon="i-heroicons-arrow-path"
>
Restablecer
</UButton>
</div>
</div>
</details>
</div>
<!-- Actions -->
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t dark:border-gray-700">
<UButton
@click="executeQuery"
:loading="executing"
color="primary"
size="sm"
>
Ejecutar Query
</UButton>
<UButton
@click="executeQueryCached"
:loading="executingCached"
color="gray"
variant="soft"
size="sm"
>
Con Caché
</UButton>
<UDropdown :items="exportItems">
<UButton
color="gray"
variant="soft"
size="sm"
trailing-icon="i-heroicons-chevron-down-20-solid"
>
Exportar
</UButton>
</UDropdown>
</div>
<!-- Query Results -->
<div v-if="queryResult" class="mt-4">
<div class="flex flex-wrap justify-between items-center gap-2 mb-2">
<h4 class="font-medium text-sm">Resultados</h4>
<div class="flex items-center gap-2">
<UBadge color="green">
{{ queryResult.data?.rows?.length || 0 }} filas en {{ queryResult.running_time || 0 }}ms
</UBadge>
<UButton
v-if="queryResult.data"
@click="copyResults"
color="gray"
variant="soft"
size="xs"
icon="i-heroicons-clipboard-document"
>
{{ copied ? 'Copiado!' : 'Copiar JSON' }}
</UButton>
</div>
</div>
<!-- Empty State -->
<div v-if="!queryResult.data?.rows || queryResult.data.rows.length === 0" class="p-8 text-center bg-gray-50 dark:bg-gray-900 rounded">
<div class="mx-auto w-16 h-16 mb-4 flex items-center justify-center bg-gray-200 dark:bg-gray-800 rounded-full">
<svg class="w-8 h-8 text-gray-400" 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-gray-900 dark:text-gray-100 mb-1">No hay datos</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">La consulta se ejecutó correctamente pero no devolvió resultados.</p>
</div>
<!-- Data Display -->
<div v-else>
<!-- Table View -->
<div v-if="queryResult.data.cols && queryResult.data.rows" class="overflow-x-auto mb-4">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th
v-for="col in queryResult.data.cols"
:key="col.name"
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{{ col.display_name || col.name }}
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="(row, rowIndex) in queryResult.data.rows" :key="rowIndex">
<td
v-for="(cell, cellIndex) in row"
:key="cellIndex"
class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
>
{{ formatCell(cell, queryResult.data.cols[cellIndex]) }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- JSON View (collapsible) -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-primary flex items-center gap-2">
<span>Ver JSON completo</span>
<UIcon name="i-heroicons-chevron-down-20-solid" class="w-4 h-4 transition-transform group-open:rotate-180" />
</summary>
<pre class="mt-2 p-3 bg-gray-50 dark:bg-gray-900 rounded text-xs whitespace-pre-wrap break-words">{{ JSON.stringify(queryResult.data, null, 2) }}</pre>
</details>
</div>
</div>
<!-- Error Display -->
<UAlert
v-if="error"
color="red"
variant="soft"
:title="error"
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'red', variant: 'link' }"
@close="error = null"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
const props = defineProps<{
card: any
}>()
const executing = ref(false)
const executingCached = ref(false)
const queryResult = ref<any>(null)
const error = ref<string | null>(null)
const copied = ref(false)
const showParameters = ref(false)
const parameterValues = ref<Record<string, any>>({})
// Initialize parameter values from card template tags
const templateTags = computed(() => props.card.dataset_query?.native?.['template-tags'] || {})
const hasParameters = computed(() => Object.keys(templateTags.value).length > 0)
// Initialize parameters on mount
function initializeParameters() {
const params: Record<string, any> = {}
for (const [tagName, tagConfig] of Object.entries(templateTags.value)) {
const config = tagConfig as any
if (config.type === 'boolean') {
// Para boolean, el default viene como array [false] o [true]
params[tagName] = config.default?.[0] ?? false
} else if (config.type === 'text' && (tagName.toLowerCase().includes('fecha') || config?.['display-name']?.toLowerCase().includes('fecha'))) {
// Para campos de fecha tipo text, inicializar con string vacío
params[tagName] = config.default ?? ''
} else {
params[tagName] = config.default ?? ''
}
}
parameterValues.value = params
}
// Watch for card changes and reinitialize
watch(() => props.card, () => {
initializeParameters()
}, { immediate: true })
const exportItems = [[
{
label: 'CSV',
icon: 'i-heroicons-document-text',
click: () => downloadExport('csv')
},
{
label: 'JSON',
icon: 'i-heroicons-code-bracket',
click: () => downloadExport('json')
},
{
label: 'XLSX',
icon: 'i-heroicons-table-cells',
click: () => downloadExport('xlsx')
}
]]
function getParameterType(key: string): string {
const config = templateTags.value[key] as any
const type = config?.type || 'text'
// Si es tipo text pero el nombre incluye "fecha", tratar como date
if (type === 'text' && (key.toLowerCase().includes('fecha') || config?.['display-name']?.toLowerCase().includes('fecha'))) {
return 'date'
}
return type
}
function getParameterLabel(key: string): string {
const config = templateTags.value[key] as any
return config?.['display-name'] || key
}
function resetParameters() {
initializeParameters()
}
function getStatusColor(card: any) {
if (card.archived) return 'red'
if (card.dataset) return 'blue'
return 'gray'
}
function formatDate(dateString: string) {
if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
async function executeQuery() {
executing.value = true
error.value = null
queryResult.value = null
try {
const parameters = []
// Build parameters from current form values
for (const [tagName, tagConfig] of Object.entries(templateTags.value)) {
const config = tagConfig as any
let paramType = 'string/='
let paramValue = parameterValues.value[tagName]
// Determine parameter type based on tag type
if (config.type === 'date') {
paramType = 'date/single'
paramValue = parameterValues.value[tagName] || null
} else if (config.type === 'boolean') {
paramType = 'boolean/='
paramValue = parameterValues.value[tagName]
} else if (config.type === 'number') {
paramType = 'number/='
paramValue = parameterValues.value[tagName] || null
} else if (config.type === 'text') {
paramType = 'string/='
paramValue = parameterValues.value[tagName] || null
}
parameters.push({
type: paramType,
target: ['variable', ['template-tag', tagName]],
value: paramValue
})
}
const result = await $fetch(`/api/metabase/cards/${props.card.id}/query`, {
method: 'POST',
body: { parameters }
})
queryResult.value = result
} catch (e: any) {
error.value = e.message || 'Error al ejecutar la query'
} finally {
executing.value = false
}
}
async function executeQueryCached() {
executingCached.value = true
error.value = null
queryResult.value = null
try {
const result = await $fetch(`/api/metabase/cards/${props.card.id}/query`)
queryResult.value = result
} catch (e: any) {
error.value = e.message || 'Error al ejecutar la query con caché'
} finally {
executingCached.value = false
}
}
function downloadExport(format: 'csv' | 'json' | 'xlsx') {
const url = `${window.location.origin}/api/metabase/cards/${props.card.id}/query/${format}`
window.open(url, '_blank')
}
async function copyResults() {
try {
await navigator.clipboard.writeText(JSON.stringify(queryResult.value.data, null, 2))
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (e) {
console.error('Error al copiar:', e)
}
}
function formatCell(value: any, col: any) {
if (value === null || value === undefined) return '-'
// Format numbers based on type
if (col.base_type === 'type/Float' || col.base_type === 'type/Decimal') {
return new Intl.NumberFormat('es-ES', {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(value)
}
if (col.base_type === 'type/Integer') {
return new Intl.NumberFormat('es-ES').format(value)
}
// Format dates
if (col.base_type === 'type/Date' || col.base_type === 'type/DateTime') {
return new Date(value).toLocaleDateString('es-ES')
}
return value
}
</script>