Feat: Agregar página Comparativa UVA vs Carretas
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 4m22s
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 4m22s
- Nuevo endpoint API para ejecutar Card 94 de Metabase - Página con filtros de fecha, cards de totales y tabla completa - Colores de rendimiento: verde (95-105%), amarillo, rojo - Enlace agregado al sidebar
This commit is contained in:
@@ -309,6 +309,12 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
|||||||
to: '/comparativa-cosechas',
|
to: '/comparativa-cosechas',
|
||||||
active: route.path === '/comparativa-cosechas'
|
active: route.path === '/comparativa-cosechas'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'UVA vs Carretas',
|
||||||
|
icon: 'i-lucide-scale',
|
||||||
|
to: '/comparativa-uva-carretas',
|
||||||
|
active: route.path === '/comparativa-uva-carretas'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Explorador de datos',
|
label: 'Explorador de datos',
|
||||||
icon: 'i-lucide-table',
|
icon: 'i-lucide-table',
|
||||||
|
|||||||
292
nuxt4-app/app/pages/comparativa-uva-carretas.vue
Normal file
292
nuxt4-app/app/pages/comparativa-uva-carretas.vue
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- 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-[var(--brand-primary-strong)] border-t-transparent" />
|
||||||
|
<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" :loading="loading" @click="loadData" color="primary">
|
||||||
|
Reintentar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Filtros -->
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold brand-section-title">Comparativa Ingreso UVA vs Salida Carretas</h2>
|
||||||
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||||
|
Peso seco de ingresos UVA comparado con salidas ajustadas por humedad
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<!-- Fechas -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 flex-1">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-[var(--brand-text-muted)]">Fecha desde</label>
|
||||||
|
<UInput v-model="fechaDesde" type="date" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-[var(--brand-text-muted)]">Fecha hasta</label>
|
||||||
|
<UInput v-model="fechaHasta" type="date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<UButton
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
:ui="{ base: 'bg-[var(--brand-primary-strong)] text-[var(--brand-bg)] border border-[var(--brand-primary)] hover:bg-[var(--brand-primary)] hover:border-[var(--brand-accent)]' }"
|
||||||
|
@click="loadData"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': loading }" />
|
||||||
|
</template>
|
||||||
|
Actualizar
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Totales -->
|
||||||
|
<div v-if="data" class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wider">Ingreso UVA</span>
|
||||||
|
<span class="text-2xl font-bold text-[var(--brand-text)]">{{ formatNumber(data.totales.ingreso_qq) }} qq</span>
|
||||||
|
<span class="text-sm text-[var(--brand-text-muted)]">{{ formatNumber(data.totales.ingreso_lb) }} lb</span>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wider">Salida Total</span>
|
||||||
|
<span class="text-2xl font-bold text-[var(--brand-text)]">{{ formatNumber(data.totales.salida_lb) }} lb</span>
|
||||||
|
<span class="text-sm text-[var(--brand-text-muted)]">Peso seco ajustado</span>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wider">Diferencia</span>
|
||||||
|
<span
|
||||||
|
class="text-2xl font-bold"
|
||||||
|
:class="data.totales.diferencia_lb >= 0 ? 'text-green-400' : 'text-red-400'"
|
||||||
|
>
|
||||||
|
{{ data.totales.diferencia_lb >= 0 ? '+' : '' }}{{ formatNumber(data.totales.diferencia_lb) }} lb
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-[var(--brand-text-muted)]">Salida - Ingreso</span>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wider">Rendimiento</span>
|
||||||
|
<span
|
||||||
|
class="text-2xl font-bold"
|
||||||
|
:class="getRendimientoColor(data.totales.rendimiento_promedio)"
|
||||||
|
>
|
||||||
|
{{ data.totales.rendimiento_promedio }}%
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-[var(--brand-text-muted)]">Promedio ponderado</span>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla de Datos -->
|
||||||
|
<UCard v-if="data && data.datos.length > 0" class="brand-card border border-transparent">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold brand-section-title">Detalle por Fecha</h3>
|
||||||
|
<span class="text-sm text-[var(--brand-text-muted)]">{{ data.datos.length }} registros</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-[var(--brand-border)]">
|
||||||
|
<th class="text-left py-3 px-2 text-[var(--brand-text-muted)] font-medium">Fecha</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Ingreso qq</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Ingreso lb</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Primera Neto</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Rechazo Neto</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Primera Seco</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Rechazo Seco</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Salida Total</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Diferencia</th>
|
||||||
|
<th class="text-right py-3 px-2 text-[var(--brand-text-muted)] font-medium">Rend%</th>
|
||||||
|
<th class="text-center py-3 px-2 text-[var(--brand-text-muted)] font-medium">Err</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in data.datos"
|
||||||
|
:key="row.fecha"
|
||||||
|
class="border-b border-[var(--brand-border)]/50 hover:bg-[var(--brand-bg-muted)]/50 transition-colors"
|
||||||
|
:class="{ 'opacity-50': !row.salida_total_lb || row.salida_total_lb === 0 }"
|
||||||
|
>
|
||||||
|
<td class="py-2 px-2 text-[var(--brand-text)]">{{ formatFecha(row.fecha) }}</td>
|
||||||
|
<td class="py-2 px-2 text-right text-[var(--brand-text)]">{{ row.ingreso_uva_qq }}</td>
|
||||||
|
<td class="py-2 px-2 text-right text-[var(--brand-text)]">{{ formatNumber(row.ingreso_uva_lb) }}</td>
|
||||||
|
<td class="py-2 px-2 text-right text-[var(--brand-text)]">{{ formatNumber(row.primera_neto_lb) }}</td>
|
||||||
|
<td class="py-2 px-2 text-right text-[var(--brand-text)]">{{ formatNumber(row.rechazo_neto_lb) }}</td>
|
||||||
|
<td class="py-2 px-2 text-right text-[var(--brand-text)]">{{ formatNumber(row.primera_seco_lb) }}</td>
|
||||||
|
<td class="py-2 px-2 text-right text-[var(--brand-text)]">{{ formatNumber(row.rechazo_seco_lb) }}</td>
|
||||||
|
<td class="py-2 px-2 text-right font-semibold text-[var(--brand-text)]">{{ formatNumber(row.salida_total_lb) }}</td>
|
||||||
|
<td
|
||||||
|
class="py-2 px-2 text-right font-medium"
|
||||||
|
:class="row.diferencia_lb >= 0 ? 'text-green-400' : 'text-red-400'"
|
||||||
|
>
|
||||||
|
{{ row.diferencia_lb >= 0 ? '+' : '' }}{{ formatNumber(row.diferencia_lb) }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 text-right">
|
||||||
|
<span
|
||||||
|
v-if="row.rendimiento_pct"
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
:class="getRendimientoBadgeClass(row.rendimiento_pct)"
|
||||||
|
>
|
||||||
|
{{ row.rendimiento_pct }}%
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-[var(--brand-text-muted)]">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 text-center">
|
||||||
|
<span v-if="row.errores > 0" class="text-red-400">{{ row.errores }}</span>
|
||||||
|
<span v-else class="text-[var(--brand-text-muted)]">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- No Data -->
|
||||||
|
<UCard v-else-if="data && data.datos.length === 0" class="brand-card border border-transparent">
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4 py-10 text-center">
|
||||||
|
<UIcon name="i-lucide-inbox" class="w-12 h-12 text-[var(--brand-text-muted)]" />
|
||||||
|
<p class="text-[var(--brand-text-muted)]">No hay datos para el rango de fechas seleccionado</p>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'dashboard',
|
||||||
|
title: 'Comparativa UVA vs Carretas'
|
||||||
|
})
|
||||||
|
|
||||||
|
interface ComparativaRow {
|
||||||
|
fecha: string
|
||||||
|
ingreso_uva_qq: number
|
||||||
|
ingreso_uva_lb: number
|
||||||
|
primera_neto_lb: number
|
||||||
|
rechazo_neto_lb: number
|
||||||
|
primera_seco_lb: number
|
||||||
|
rechazo_seco_lb: number
|
||||||
|
salida_total_lb: number
|
||||||
|
diferencia_lb: number
|
||||||
|
rendimiento_pct: number | null
|
||||||
|
errores: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComparativaData {
|
||||||
|
datos: ComparativaRow[]
|
||||||
|
totales: {
|
||||||
|
ingreso_qq: number
|
||||||
|
ingreso_lb: number
|
||||||
|
salida_lb: number
|
||||||
|
diferencia_lb: number
|
||||||
|
rendimiento_promedio: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const data = ref<ComparativaData | null>(null)
|
||||||
|
|
||||||
|
// Default dates: Nov 1 to today
|
||||||
|
const today = new Date()
|
||||||
|
const novFirst = new Date(2025, 10, 1) // November 1, 2025
|
||||||
|
|
||||||
|
const toDateString = (d: Date) => {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fechaDesde = ref(toDateString(novFirst))
|
||||||
|
const fechaHasta = ref(toDateString(today))
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<ComparativaData>('/api/metabase/comparativa-uva-carretas', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
fecha_desde: fechaDesde.value,
|
||||||
|
fecha_hasta: fechaHasta.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
data.value = response
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error loading comparativa:', err)
|
||||||
|
error.value = err.message || 'Error al cargar datos'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format helpers
|
||||||
|
function formatNumber(num: number | null | undefined): string {
|
||||||
|
if (num === null || num === undefined) return '-'
|
||||||
|
return num.toLocaleString('es-HN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFecha(fecha: string): string {
|
||||||
|
if (!fecha) return '-'
|
||||||
|
const d = new Date(fecha)
|
||||||
|
const months = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
||||||
|
return `${months[d.getMonth()]}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRendimientoColor(pct: number): string {
|
||||||
|
if (pct >= 95 && pct <= 105) return 'text-green-400'
|
||||||
|
if ((pct >= 85 && pct < 95) || (pct > 105 && pct <= 115)) return 'text-yellow-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRendimientoBadgeClass(pct: number): string {
|
||||||
|
if (pct >= 95 && pct <= 105) return 'bg-green-500/20 text-green-400'
|
||||||
|
if ((pct >= 85 && pct < 95) || (pct > 105 && pct <= 115)) return 'bg-yellow-500/20 text-yellow-400'
|
||||||
|
return 'bg-red-500/20 text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
107
nuxt4-app/server/api/metabase/comparativa-uva-carretas.post.ts
Normal file
107
nuxt4-app/server/api/metabase/comparativa-uva-carretas.post.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* API endpoint for Comparativa Ingreso UVA vs Salida Carretas
|
||||||
|
* Executes Card 94 from Metabase and returns processed data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CARD_ID = 94 // Comparativa Ingreso UVA vs Salida (Nov-Dic 2025)
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const {
|
||||||
|
fecha_desde = null,
|
||||||
|
fecha_hasta = null
|
||||||
|
} = body
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Comparativa UVA] Executing card ${CARD_ID}`)
|
||||||
|
|
||||||
|
// Execute the card without parameters (dates are hardcoded in the card)
|
||||||
|
const result = await executeCardQuery(CARD_ID, [])
|
||||||
|
|
||||||
|
if (!result.data?.rows || !result.data?.cols) {
|
||||||
|
console.warn('[Comparativa UVA] No data returned from Metabase')
|
||||||
|
return {
|
||||||
|
datos: [],
|
||||||
|
totales: {
|
||||||
|
ingreso_qq: 0,
|
||||||
|
ingreso_lb: 0,
|
||||||
|
salida_lb: 0,
|
||||||
|
diferencia_lb: 0,
|
||||||
|
rendimiento_promedio: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform rows to objects
|
||||||
|
const cols = result.data.cols
|
||||||
|
let datos = result.data.rows.map((row: any[]) => {
|
||||||
|
const obj: Record<string, any> = {}
|
||||||
|
cols.forEach((col: any, index: number) => {
|
||||||
|
obj[col.name] = row[index]
|
||||||
|
})
|
||||||
|
return obj
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Comparativa UVA] Retrieved ${datos.length} rows`)
|
||||||
|
|
||||||
|
// Filter by dates if provided
|
||||||
|
if (fecha_desde || fecha_hasta) {
|
||||||
|
datos = datos.filter((row: any) => {
|
||||||
|
if (!row.fecha) return false
|
||||||
|
|
||||||
|
const rowDate = new Date(row.fecha)
|
||||||
|
|
||||||
|
if (fecha_desde) {
|
||||||
|
const desde = new Date(fecha_desde)
|
||||||
|
if (rowDate < desde) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fecha_hasta) {
|
||||||
|
const hasta = new Date(fecha_hasta)
|
||||||
|
if (rowDate > hasta) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Comparativa UVA] After date filter: ${datos.length} rows`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totales = datos.reduce((acc: any, row: any) => {
|
||||||
|
acc.ingreso_qq += Number(row.ingreso_uva_qq) || 0
|
||||||
|
acc.ingreso_lb += Number(row.ingreso_uva_lb) || 0
|
||||||
|
acc.salida_lb += Number(row.salida_total_lb) || 0
|
||||||
|
acc.diferencia_lb += Number(row.diferencia_lb) || 0
|
||||||
|
return acc
|
||||||
|
}, {
|
||||||
|
ingreso_qq: 0,
|
||||||
|
ingreso_lb: 0,
|
||||||
|
salida_lb: 0,
|
||||||
|
diferencia_lb: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate weighted average rendimiento
|
||||||
|
totales.rendimiento_promedio = totales.ingreso_lb > 0
|
||||||
|
? Math.round((totales.salida_lb / totales.ingreso_lb) * 1000) / 10
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// Round totals
|
||||||
|
totales.ingreso_qq = Math.round(totales.ingreso_qq * 100) / 100
|
||||||
|
totales.ingreso_lb = Math.round(totales.ingreso_lb)
|
||||||
|
totales.salida_lb = Math.round(totales.salida_lb)
|
||||||
|
totales.diferencia_lb = Math.round(totales.diferencia_lb)
|
||||||
|
|
||||||
|
return {
|
||||||
|
datos,
|
||||||
|
totales
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Comparativa UVA] Failed to execute query:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || 'Failed to execute comparativa UVA query'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user