Files
analiticaNucleo/nuxt4-app/app/pages/comparativa-uva-carretas.vue
josedario87 979e219cb2
Some checks failed
build-and-deploy / build-and-deploy (push) Failing after 4m22s
Feat: Agregar página Comparativa UVA vs Carretas
- 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
2025-12-20 11:13:30 -06:00

293 lines
12 KiB
Vue

<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>