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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user