feat: restaurar panorama facturador con nueva arquitectura basada en Metabase
All checks were successful
build-and-deploy / build (push) Successful in 43s
build-and-deploy / deploy (push) Successful in 4s

- Crear endpoint /api/metabase/panorama.post.ts que ejecuta las 9 queries en paralelo
- Restaurar y adaptar panorama.vue para usar el nuevo endpoint
- Crear componentes auxiliares: SecosVendidos, TotalesIngresoCompra, TotalesMonetarios, TotalesVerde, MetricBox, RechazosRechazoCard
- Adaptar RechazosSubproductos para recibir data directamente de Metabase
- Toda la transformación de datos ocurre en las queries SQL de Metabase
- Sin uso de stores ni composables de métricas
- Agregar documentación de queries en archivos MD
This commit is contained in:
2025-10-14 10:34:27 -06:00
parent 49352de748
commit f8c53da6fc
12 changed files with 2823 additions and 46 deletions

View File

@@ -0,0 +1,27 @@
<template>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">{{ label }}</div>
<div class="text-lg font-bold" :class="valueColor">
{{ value }}
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string
value: string
color?: 'default' | 'green' | 'yellow' | 'red' | 'blue'
}>()
const valueColor = computed(() => {
const colors = {
default: 'text-[var(--brand-text)]',
green: 'text-green-400',
yellow: 'text-yellow-400',
red: 'text-red-400',
blue: 'text-blue-400'
}
return colors[props.color || 'default']
})
</script>

View File

@@ -0,0 +1,74 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Café Seco - Inventario y Proyecciones</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">QQ Seco por Vender</div>
<div class="text-2xl font-bold text-[var(--brand-text)]">
{{ formatNumber(data.total_qq_seco_por_vender) }} QQ
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Precio Venta Promedio/QQ</div>
<div class="text-2xl font-bold text-green-400">
{{ formatCurrency(data.precio_venta_promedio_por_qq) }}
</div>
<div v-if="data.precio_venta_promedio_por_qq === 0" class="text-xs text-[var(--brand-text-muted)] mt-1">
Sin ventas registradas
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Precio Compra Promedio/QQ</div>
<div class="text-2xl font-bold text-[var(--brand-text)]">
{{ formatCurrency(data.precio_compra_promedio_por_qq) }}
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Margen de Ganancia/QQ</div>
<div class="text-2xl font-bold" :class="margenColor">
{{ formatCurrency(data.margen_ganancia_por_qq) }}
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
total_qq_seco_por_vender: number
precio_venta_promedio_por_qq: number
precio_compra_promedio_por_qq: number
margen_ganancia_por_qq: number
}
}>()
const margenColor = computed(() => {
if (props.data.margen_ganancia_por_qq > 0) return 'text-green-400'
if (props.data.margen_ganancia_por_qq < 0) return 'text-red-400'
return 'text-[var(--brand-text)]'
})
const formatNumber = (value: number) => {
return new Intl.NumberFormat('es-HN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value)
}
const formatCurrency = (value: number) => {
if (!value) return 'L 0.00'
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Totales de Ingreso y Compra</h2>
</template>
<div class="space-y-6">
<!-- Uva -->
<div>
<h3 class="text-lg font-semibold text-[var(--brand-primary)] mb-3">Uva</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<MetricBox label="LB Uva Ingresada" :value="formatNumber(data.total_lb_uva_ingresada) + ' lb'" />
<MetricBox label="QQ Seco Ingresado" :value="formatNumber(data.total_qq_seco_uva_ingresado) + ' QQ'" />
<MetricBox label="LB Uva Pagada" :value="formatNumber(data.total_lb_uva_pagada) + ' lb'" color="green" />
<MetricBox label="LB Uva en Depósito" :value="formatNumber(data.total_lb_uva_deposito) + ' lb'" color="yellow" />
</div>
</div>
<!-- Mojado -->
<div>
<h3 class="text-lg font-semibold text-[var(--brand-primary)] mb-3">Mojado</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricBox label="QQ Seco Ingresado" :value="formatNumber(data.total_qq_seco_mojado_ingresado) + ' QQ'" />
<MetricBox label="QQ Seco Pagado" :value="formatNumber(data.total_qq_seco_mojado_pagado) + ' QQ'" color="green" />
<MetricBox label="QQ en Depósito" :value="formatNumber(data.total_qq_mojado_deposito) + ' QQ'" color="yellow" />
</div>
</div>
<!-- Oreado -->
<div>
<h3 class="text-lg font-semibold text-[var(--brand-primary)] mb-3">Oreado</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricBox label="QQ Seco Ingresado" :value="formatNumber(data.total_qq_seco_oreado_ingresado) + ' QQ'" />
<MetricBox label="QQ Seco Pagado" :value="formatNumber(data.total_qq_seco_oreado_pagado) + ' QQ'" color="green" />
<MetricBox label="QQ en Depósito" :value="formatNumber(data.total_qq_oreado_deposito) + ' QQ'" color="yellow" />
</div>
</div>
<!-- Totales Generales -->
<div class="pt-4 border-t border-[#3a2a16]">
<h3 class="text-lg font-semibold text-[var(--brand-primary)] mb-3">Totales Generales</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<MetricBox label="QQ Seco Ingresado" :value="formatNumber(data.total_qq_seco_ingresado) + ' QQ'" />
<MetricBox label="QQ Seco Comprado" :value="formatNumber(data.total_qq_seco_comprado) + ' QQ'" color="green" />
<MetricBox label="QQ Seco en Depósito" :value="formatNumber(data.total_qq_seco_deposito) + ' QQ'" color="yellow" />
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
total_lb_uva_ingresada: number
total_qq_seco_uva_ingresado: number
total_qq_seco_mojado_ingresado: number
total_qq_seco_oreado_ingresado: number
total_qq_seco_ingresado: number
total_lb_uva_pagada: number
total_qq_seco_uva_pagado: number
total_qq_seco_mojado_pagado: number
total_qq_seco_oreado_pagado: number
total_qq_seco_comprado: number
total_lb_uva_deposito: number
total_qq_mojado_deposito: number
total_qq_oreado_deposito: number
total_qq_seco_deposito: number
}
}>()
const formatNumber = (value: number) => {
return new Intl.NumberFormat('es-HN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value)
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Totales Monetarios</h2>
</template>
<div class="space-y-6">
<!-- Inversión Hasta la Fecha -->
<div>
<h3 class="text-lg font-semibold text-[var(--brand-primary)] mb-3">Inversión Hasta la Fecha</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<MetricBox label="Inversión Uva" :value="formatCurrency(data.inversion_uva)" />
<MetricBox label="Inversión Mojado" :value="formatCurrency(data.inversion_mojado)" />
<MetricBox label="Inversión Oreado" :value="formatCurrency(data.inversion_oreado)" />
<MetricBox label="Total Invertido" :value="formatCurrency(data.total_invertido)" color="green" />
</div>
</div>
<!-- Precios Promedio Ponderados -->
<div>
<h3 class="text-lg font-semibold text-[var(--brand-primary)] mb-3">Precios Promedio Ponderados</h3>
<div class="grid grid-cols-1 md:grid-cols-5 gap-3">
<MetricBox label="Uva por LB" :value="formatCurrency(data.precio_promedio_uva_por_lb)" />
<MetricBox label="Uva por QQ" :value="formatCurrency(data.precio_promedio_uva_por_qq)" />
<MetricBox label="Mojado por QQ" :value="formatCurrency(data.precio_promedio_mojado_por_qq)" />
<MetricBox label="Oreado por QQ" :value="formatCurrency(data.precio_promedio_oreado_por_qq)" />
<MetricBox label="Global QQ Seco" :value="formatCurrency(data.precio_promedio_qq_seco)" color="blue" />
</div>
</div>
<!-- Inversión Restante Esperada -->
<div>
<h3 class="text-lg font-semibold text-[var(--brand-primary)] mb-3">Inversión Restante Esperada</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<MetricBox label="Restante Uva" :value="formatCurrency(data.inversion_restante_uva)" color="yellow" />
<MetricBox label="Restante Mojado" :value="formatCurrency(data.inversion_restante_mojado)" color="yellow" />
<MetricBox label="Restante Oreado" :value="formatCurrency(data.inversion_restante_oreado)" color="yellow" />
<MetricBox label="Total Restante" :value="formatCurrency(data.inversion_restante_esperada)" color="red" />
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
inversion_uva: number
inversion_mojado: number
inversion_oreado: number
total_invertido: number
precio_promedio_uva_por_lb: number
precio_promedio_uva_por_qq: number
precio_promedio_mojado_por_qq: number
precio_promedio_oreado_por_qq: number
precio_promedio_qq_seco: number
inversion_restante_uva: number
inversion_restante_mojado: number
inversion_restante_oreado: number
inversion_restante_esperada: number
}
}>()
const formatCurrency = (value: number) => {
if (!value) return 'L 0.00'
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Café Verde</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">LB Neto Verde Total</div>
<div class="text-2xl font-bold text-[var(--brand-text)]">
{{ formatNumber(data.total_lb_neto_verde) }} lb
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">LB Neto Comprado</div>
<div class="text-2xl font-bold text-green-400">
{{ formatNumber(data.total_lb_neto_comprado_verde) }} lb
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">LB Neto en Depósito</div>
<div class="text-2xl font-bold text-yellow-400">
{{ formatNumber(data.total_lb_neto_verde_deposito) }} lb
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Precio Promedio Pagado</div>
<div class="text-2xl font-bold text-[var(--brand-text)]">
{{ formatCurrency(data.precio_promedio_verde_pagado) }}/lb
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Inversión Hasta la Fecha</div>
<div class="text-2xl font-bold text-green-400">
{{ formatCurrency(data.inversion_verde_hasta_fecha) }}
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Inversión Restante</div>
<div class="text-2xl font-bold text-yellow-400">
{{ formatCurrency(data.inversion_restante_verde) }}
</div>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
total_lb_neto_verde: number
precio_promedio_verde_pagado: number
total_lb_neto_verde_deposito: number
inversion_verde_hasta_fecha: number
inversion_restante_verde: number
total_lb_neto_comprado_verde: number
}
}>()
const formatNumber = (value: number) => {
return new Intl.NumberFormat('es-HN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value)
}
const formatCurrency = (value: number) => {
if (!value) return 'L 0.00'
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-[var(--brand-text)]">{{ title }}</h3>
<div :class="colorClasses" class="w-3 h-3 rounded-full"></div>
</div>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-xs text-[var(--brand-text-muted)]">Cantidad</span>
<span class="text-sm font-medium text-[var(--brand-text)]">
{{ formatNumber(data.total_cantidad) }} {{ unidad }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-xs text-[var(--brand-text-muted)]">Total Cobrado</span>
<span class="text-sm font-bold text-green-400">
{{ formatCurrency(data.total_cobrado) }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-xs text-[var(--brand-text-muted)]">Precio Promedio</span>
<span class="text-sm font-medium text-[var(--brand-text)]">
{{ formatCurrency(data.precio_promedio) }}
</span>
</div>
<div class="flex justify-between items-center pt-2 border-t border-[#3a2a16]">
<span class="text-xs text-[var(--brand-text-muted)]">Registros</span>
<span class="text-xs font-medium text-[var(--brand-text)]">
{{ data.num_registros }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
title: string
data: {
tipo: string
num_registros: number
total_cantidad: number
total_cobrado: number
precio_promedio: number
}
unidad: string
color: string
}>()
const colorClasses = computed(() => {
const colors: Record<string, string> = {
blue: 'bg-blue-500',
green: 'bg-green-500',
yellow: 'bg-yellow-500',
red: 'bg-red-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500'
}
return colors[props.color] || 'bg-gray-500'
})
const formatNumber = (value: number) => {
return new Intl.NumberFormat('es-HN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value)
}
const formatCurrency = (value: number) => {
if (!value) return 'L 0.00'
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -5,62 +5,71 @@
<h2 class="text-xl font-bold brand-section-title">Rechazos y Subproductos</h2>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-2">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Total Rechazos</div>
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ formatCurrency(totalRechazos.value) }}</div>
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ formatCurrency(totalRechazos) }}</div>
</div>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<RechazosRechazoCard
title="Chibolita"
:metrics="metrics.chibolita"
unidad="libras"
color="blue"
/>
<RechazosRechazoCard
title="Perico"
:metrics="metrics.perico"
unidad="libras"
color="green"
/>
<RechazosRechazoCard
title="Vano"
:metrics="metrics.vano"
unidad="galones"
color="yellow"
/>
<RechazosRechazoCard
title="Picadillo"
:metrics="metrics.picadillo"
unidad="galones"
color="red"
/>
<RechazosRechazoCard
title="Magalla"
:metrics="metrics.magalla"
unidad="galones"
color="purple"
/>
<RechazosRechazoCard
title="Pinta"
:metrics="metrics.pinta"
unidad="libras"
color="pink"
v-for="rechazo in rechazosFormateados"
:key="rechazo.tipo"
:title="rechazo.title"
:data="rechazo.data"
:unidad="rechazo.unidad"
:color="rechazo.color"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { RechazosMetrics } from '~/composables/useRechazosMetrics'
const props = defineProps<{
metrics: RechazosMetrics
data: Array<{
tipo: string
num_registros: number
total_cantidad: number
total_cobrado: number
precio_promedio: number
}>
}>()
const totalRechazos = computed(() => props.metrics.totalRechazos)
// Calcular total de rechazos
const totalRechazos = computed(() => {
return props.data.reduce((sum, r) => sum + (r.total_cobrado || 0), 0)
})
// Formatear rechazos para los cards
const rechazosFormateados = computed(() => {
const config = {
chibolita: { title: 'Chibolita', unidad: 'libras', color: 'blue' },
perico: { title: 'Perico', unidad: 'libras', color: 'green' },
vano: { title: 'Vano', unidad: 'galones', color: 'yellow' },
picadillo: { title: 'Picadillo', unidad: 'galones', color: 'red' },
magalla: { title: 'Magalla', unidad: 'galones', color: 'purple' },
pinta: { title: 'Pinta', unidad: 'libras', color: 'pink' }
}
return Object.entries(config).map(([tipo, cfg]) => {
const rechazo = props.data.find(r => r.tipo === tipo)
return {
tipo,
title: cfg.title,
unidad: cfg.unidad,
color: cfg.color,
data: rechazo || {
tipo,
num_registros: 0,
total_cantidad: 0,
total_cobrado: 0,
precio_promedio: 0
}
}
})
})
const formatCurrency = (value: number) => {
if (!value) return 'L 0.00'
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
@@ -68,4 +77,4 @@ const formatCurrency = (value: number) => {
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>
</script>

View File

@@ -1,15 +1,248 @@
<template>
<MaintenanceMode
title="Panorama Facturador"
description="El panel de panorama facturador está temporalmente deshabilitado mientras actualizamos las métricas."
icon="i-lucide-bar-chart-3"
technical-info="Dashboard de facturación en proceso de integración con nueva fuente de datos empresariales."
/>
<div class="flex flex-col gap-8">
<!-- Loading State -->
<UCard v-if="loading && !data" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
<div class="flex items-center gap-3">
<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]">Cargando datos...</span>
</div>
</div>
</UCard>
<!-- Error State -->
<div v-else-if="error" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
<p>Error al cargar datos: {{ error }}</p>
<UButton class="mt-4" @click="loadData" color="primary">
Reintentar
</UButton>
</div>
<!-- Main Content -->
<template v-else-if="data">
<!-- Card de Filtros -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-bold brand-section-title">Filtros y Configuraciones</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
Aplicados a <code>created_at</code> de ingresos y rechazos
</p>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="includeAnulados" label="Incluir anulados" @update:model-value="onToggleAnulados" />
</div>
</div>
<!-- Alerta roja cuando incluye anulados -->
<UAlert
v-if="includeAnulados"
color="error"
variant="solid"
icon="i-lucide-alert-triangle"
title="Incluir anulados activado"
description="Los cálculos incluyen registros anulados. Esto puede afectar los resultados financieros."
/>
</div>
</template>
<DateRangeSelector
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
@update:selected-preset="onUpdatePreset"
@update:fecha-desde="onUpdateFechaDesde"
@update:fecha-hasta="onUpdateFechaHasta"
/>
<template #footer>
<div class="text-xs text-[var(--brand-text-muted)]">
Rango activo: {{ rangoLegible }} · Registros considerados: Ingresos {{ data.conteos.ingresos_filtrados }}/{{ data.conteos.ingresos_total }} · Rechazos {{ data.conteos.rechazos_filtrados }}/{{ data.conteos.rechazos_total }}
</div>
</template>
</UCard>
<!-- Totales Financieros - Resumen Principal -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold brand-section-title">Totales Financieros</h2>
<p class="text-sm text-[var(--brand-text-muted)] mt-1">Vista general de ingresos, inversiones y rechazos</p>
</div>
<UButton
:loading="loading"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
size="sm"
@click="loadData"
>
<template #leading>
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': loading }" />
</template>
Actualizar
</UButton>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-6 py-4">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Total Invertido en Café</div>
<div class="text-3xl font-bold text-[var(--brand-primary)]">
{{ formatCurrency(data.financieros.total_invertido_cafe) }}
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-6 py-4">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Total Rechazos</div>
<div class="text-3xl font-bold text-green-400">
{{ formatCurrency(data.financieros.total_rechazos) }}
</div>
</div>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-6 py-4">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">Balance Neto</div>
<div class="text-3xl font-bold text-[var(--brand-text)]">
{{ formatCurrency(data.financieros.balance_neto) }}
</div>
</div>
</div>
<template #footer>
<div class="text-xs text-[var(--brand-text-muted)]">
Última actualización: {{ lastUpdated }}
</div>
</template>
</UCard>
<!-- Secciones de Ingresos -->
<SecosVendidos :data="data.secosVendidos" />
<TotalesIngresoCompra :data="data.ingresoCompra" />
<TotalesMonetarios :data="data.monetarios" />
<TotalesVerde :data="data.verde" />
<!-- Sección de Rechazos -->
<RechazosSubproductos :data="data.rechazos" />
</template>
</div>
</template>
<script setup lang="ts">
// Define page metadata
definePageMeta({
layout: 'informe',
title: 'Panorama Facturador'
})
// Reactive state
const data = ref<any>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const lastUpdated = ref<string>('')
// Filtros
const includeAnulados = ref(false)
type PresetValue =
| '' | 'custom' | 'hoy' | 'semana' | 'mes' | 'ytd'
| 'cosecha-20-21' | 'cosecha-21-22' | 'cosecha-22-23'
| 'cosecha-23-24' | 'cosecha-24-25' | 'cosecha-25-26'
const selectedPreset = ref<PresetValue>('cosecha-25-26')
const fechaDesde = ref<string | null>(null)
const fechaHasta = ref<string | null>(null)
// Computed
const rangoLegible = computed(() => {
if (!fechaDesde.value && !fechaHasta.value) return 'Sin filtro de fecha'
const f = fechaDesde.value ?? '—'
const t = fechaHasta.value ?? '—'
return `${f}${t}`
})
// Format currency helper
const formatCurrency = (value: number) => {
if (!value) return 'L 0.00'
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
// Methods
async function loadData() {
loading.value = true
error.value = null
try {
const result = await $fetch('/api/metabase/panorama', {
method: 'POST',
body: {
fecha_desde: fechaDesde.value,
fecha_hasta: fechaHasta.value,
incluir_anulados: includeAnulados.value
}
})
data.value = result
lastUpdated.value = new Date().toLocaleString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch (err: any) {
error.value = err.message || 'Error al cargar datos'
console.error('Error loading panorama data:', err)
} finally {
loading.value = false
}
}
async function onToggleAnulados(newValue: boolean | 'indeterminate') {
if (newValue === true) {
// Pedir confirmación al activar
const confirmed = confirm(
'⚠️ ADVERTENCIA\n\n' +
'Está a punto de incluir registros ANULADOS en los cálculos.\n\n' +
'Esto puede afectar significativamente los resultados financieros y métricas.\n\n' +
'¿Está seguro de que desea continuar?'
)
if (!confirmed) {
// Si cancela, revertir el cambio
includeAnulados.value = false
return
}
}
// Recargar datos con el nuevo valor
await loadData()
}
function onUpdatePreset(value: PresetValue) {
selectedPreset.value = value
}
function onUpdateFechaDesde(value: string | null) {
fechaDesde.value = value
}
function onUpdateFechaHasta(value: string | null) {
fechaHasta.value = value
}
// Watchers - reload data when filters change
watch([fechaDesde, fechaHasta], () => {
loadData()
})
// Load data on mount
onMounted(() => {
// Default preset: cosecha 25-26
selectedPreset.value = 'cosecha-25-26'
loadData()
})
</script>

View File

@@ -0,0 +1,145 @@
/**
* Execute all panorama queries in parallel
* Returns data for the Panorama Facturador page
*/
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { fecha_desde = null, fecha_hasta = null, incluir_anulados = false } = body
try {
// First, get all cards to find our panorama queries
const allCards = await getMetabaseCards('all')
// Find our panorama queries by name
const queryNames = [
'panorama_totales_financieros_principales',
'panorama_totales_ingreso_compra',
'panorama_totales_monetarios',
'panorama_totales_verde',
'panorama_secos_vendidos',
'panorama_rechazos_subproductos',
'panorama_serie_temporal_diaria',
'panorama_top_clientes',
'panorama_conteo_registros'
]
const cards: Record<string, any> = {}
for (const name of queryNames) {
const card = allCards.find((c: any) => c.name === name)
if (!card) {
console.warn(`[Panorama] Query not found: ${name}`)
} else {
cards[name] = card
}
}
// Build parameters array for Metabase queries
const parameters = [
{
type: 'date/single',
target: ['variable', ['template-tag', 'fecha_desde']],
value: fecha_desde
},
{
type: 'date/single',
target: ['variable', ['template-tag', 'fecha_hasta']],
value: fecha_hasta
},
{
type: 'category',
target: ['variable', ['template-tag', 'incluir_anulados']],
value: incluir_anulados
}
]
// Execute all queries in parallel
const [
financieros,
ingresoCompra,
monetarios,
verde,
secosVendidos,
rechazos,
serieTemporal,
topClientes,
conteos
] = await Promise.all([
cards['panorama_totales_financieros_principales']
? executeCardQuery(cards['panorama_totales_financieros_principales'].id, parameters)
: { data: { rows: [[0, 0, 0]] } },
cards['panorama_totales_ingreso_compra']
? executeCardQuery(cards['panorama_totales_ingreso_compra'].id, parameters)
: { data: { rows: [[]] } },
cards['panorama_totales_monetarios']
? executeCardQuery(cards['panorama_totales_monetarios'].id, parameters)
: { data: { rows: [[]] } },
cards['panorama_totales_verde']
? executeCardQuery(cards['panorama_totales_verde'].id, parameters)
: { data: { rows: [[]] } },
cards['panorama_secos_vendidos']
? executeCardQuery(cards['panorama_secos_vendidos'].id, parameters)
: { data: { rows: [[]] } },
cards['panorama_rechazos_subproductos']
? executeCardQuery(cards['panorama_rechazos_subproductos'].id, parameters)
: { data: { rows: [] } },
cards['panorama_serie_temporal_diaria']
? executeCardQuery(cards['panorama_serie_temporal_diaria'].id, parameters)
: { data: { rows: [] } },
cards['panorama_top_clientes']
? executeCardQuery(cards['panorama_top_clientes'].id, parameters)
: { data: { rows: [] } },
cards['panorama_conteo_registros']
? executeCardQuery(cards['panorama_conteo_registros'].id, parameters)
: { data: { rows: [[0, 0, 0, 0]] } }
])
// Transform Metabase responses to objects for easier frontend consumption
const transformSingleRow = (result: any) => {
if (!result.data?.rows?.[0] || !result.data?.cols) return {}
const row = result.data.rows[0]
const cols = result.data.cols
const obj: any = {}
cols.forEach((col: any, index: number) => {
obj[col.name] = row[index]
})
return obj
}
const transformMultipleRows = (result: any) => {
if (!result.data?.rows || !result.data?.cols) return []
const cols = result.data.cols
return result.data.rows.map((row: any[]) => {
const obj: any = {}
cols.forEach((col: any, index: number) => {
obj[col.name] = row[index]
})
return obj
})
}
// Return all data in a structured format
return {
financieros: transformSingleRow(financieros),
ingresoCompra: transformSingleRow(ingresoCompra),
monetarios: transformSingleRow(monetarios),
verde: transformSingleRow(verde),
secosVendidos: transformSingleRow(secosVendidos),
rechazos: transformMultipleRows(rechazos),
serieTemporal: transformMultipleRows(serieTemporal),
topClientes: transformMultipleRows(topClientes),
conteos: transformSingleRow(conteos)
}
} catch (error: any) {
console.error('[API] Failed to execute panorama queries:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'Failed to execute panorama queries'
})
}
})