All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 49s
- Aplicar clase brand-card a todas las cards principales - Usar colores de variables CSS (--brand-text, --brand-text-muted, --brand-primary) - Estilizar cards de estadísticas con border-[#3a2a16] y bg-[#1c140c] - Actualizar estilos de tablas, modales y formularios - Aplicar estilo consistente a botones principales con color de marca #c08040
430 lines
15 KiB
Vue
430 lines
15 KiB
Vue
<template>
|
|
<UCard class="brand-card border border-transparent">
|
|
<template #header>
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<h3 class="text-lg font-semibold brand-section-title">{{ card.name }}</h3>
|
|
<p v-if="card.description" class="text-sm text-[var(--brand-text-muted)] 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 text-[var(--brand-text)]">
|
|
<div>
|
|
<span class="font-medium text-[var(--brand-text-muted)]">Database ID:</span>
|
|
<span class="ml-2">{{ card.database_id }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-[var(--brand-text-muted)]">Query Type:</span>
|
|
<span class="ml-2">{{ card.query_type || card.dataset_query?.type }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-[var(--brand-text-muted)]">Collection:</span>
|
|
<span class="ml-2">{{ card.collection_id || 'Root' }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-[var(--brand-text-muted)]">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-[var(--brand-primary)]">
|
|
Ver SQL
|
|
</summary>
|
|
<pre class="mt-2 p-3 rounded-lg border border-[#3a2a16] bg-[#1c140c] text-xs overflow-x-auto whitespace-pre-wrap break-words text-[var(--brand-text)]">{{ 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-[var(--brand-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 rounded-lg border border-[#3a2a16] bg-[#1c140c] 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-[var(--brand-text-muted)]">
|
|
{{ 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 rounded-md bg-[var(--brand-bg)] border-[var(--brand-border)] text-[var(--brand-text)]"
|
|
/>
|
|
|
|
<!-- Text parameter -->
|
|
<input
|
|
v-else
|
|
v-model="parameterValues[key]"
|
|
type="text"
|
|
class="px-3 py-2 text-sm border rounded-md bg-[var(--brand-bg)] border-[var(--brand-border)] text-[var(--brand-text)]"
|
|
/>
|
|
</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 border-[var(--brand-border)]">
|
|
<UButton
|
|
@click="executeQuery"
|
|
:loading="executing"
|
|
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c] disabled:opacity-50 disabled:cursor-not-allowed' }"
|
|
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 text-[var(--brand-text)]">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 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>
|
|
<!-- Table View -->
|
|
<div v-if="queryResult.data.cols && queryResult.data.rows" class="overflow-x-auto mb-4 rounded-lg border border-[#3a2a16]">
|
|
<table class="min-w-full divide-y divide-[var(--brand-border)]">
|
|
<thead class="bg-[#1c140c]">
|
|
<tr>
|
|
<th
|
|
v-for="col in queryResult.data.cols"
|
|
:key="col.name"
|
|
class="px-4 py-3 text-left text-xs font-medium text-[var(--brand-text-muted)] uppercase tracking-wider"
|
|
>
|
|
{{ col.display_name || col.name }}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-[var(--brand-bg)] divide-y divide-[var(--brand-border)]">
|
|
<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-[var(--brand-text)]"
|
|
>
|
|
{{ 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-[var(--brand-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 rounded-lg border border-[#3a2a16] bg-[#1c140c] text-xs whitespace-pre-wrap break-words text-[var(--brand-text)]">{{ 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] || ''
|
|
} 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] || ''
|
|
}
|
|
|
|
// Only add parameter if:
|
|
// 1. It's required, OR
|
|
// 2. It has a non-empty value (not null, not empty string, not empty array)
|
|
const isRequired = config.required === true
|
|
const hasValue = paramValue !== null &&
|
|
paramValue !== '' &&
|
|
paramValue !== undefined &&
|
|
(!Array.isArray(paramValue) || paramValue.length > 0)
|
|
|
|
if (isRequired || hasValue) {
|
|
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>
|