feat: restaurar panorama facturador con nueva arquitectura basada en Metabase
- 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:
27
nuxt4-app/app/components/MetricBox.vue
Normal file
27
nuxt4-app/app/components/MetricBox.vue
Normal 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>
|
||||
74
nuxt4-app/app/components/SecosVendidos.vue
Normal file
74
nuxt4-app/app/components/SecosVendidos.vue
Normal 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>
|
||||
78
nuxt4-app/app/components/TotalesIngresoCompra.vue
Normal file
78
nuxt4-app/app/components/TotalesIngresoCompra.vue
Normal 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>
|
||||
73
nuxt4-app/app/components/TotalesMonetarios.vue
Normal file
73
nuxt4-app/app/components/TotalesMonetarios.vue
Normal 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>
|
||||
81
nuxt4-app/app/components/TotalesVerde.vue
Normal file
81
nuxt4-app/app/components/TotalesVerde.vue
Normal 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>
|
||||
82
nuxt4-app/app/components/rechazos/RechazosRechazoCard.vue
Normal file
82
nuxt4-app/app/components/rechazos/RechazosRechazoCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
nuxt4-app/server/api/metabase/panorama.post.ts
Normal file
145
nuxt4-app/server/api/metabase/panorama.post.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user