pagina panorama facturador en camino

This commit is contained in:
2025-09-30 03:22:18 -06:00
parent ce71689d66
commit b171fbdb21
13 changed files with 1047 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
<template>
<div
class="p-4 rounded-lg border transition-all"
:class="[variantClasses, props.class]"
>
<div class="flex flex-col">
<span class="text-xs uppercase tracking-wide opacity-80 mb-1">{{ label }}</span>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-bold">{{ value }}</span>
<span v-if="unit" class="text-sm opacity-70">{{ unit }}</span>
</div>
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
label: string
value: string | number
unit?: string
variant?: 'default' | 'primary' | 'success' | 'danger' | 'warning' | 'info'
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default'
})
const variantClasses = computed(() => {
const variants = {
default: 'bg-[#1c140c] border-[#3a2a16] text-[var(--brand-text)]',
primary: 'bg-[#1c140c] border-[#c08040] text-[var(--brand-primary)]',
success: 'bg-[#1c140c] border-green-600/30 text-green-400',
danger: 'bg-[#1c140c] border-red-600/30 text-red-400',
warning: 'bg-[#1c140c] border-yellow-600/30 text-yellow-400',
info: 'bg-[#1c140c] border-cyan-600/30 text-cyan-400'
}
return variants[props.variant]
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Inventarios en Depósito</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Total QQ Seco en Depósito"
:value="metrics.totalQqSecoDeposito.value.toFixed(2)"
unit="QQ"
variant="warning"
/>
<MetricCard
label="Total QQ Mojado en Depósito"
:value="metrics.totalQqMojadoDeposito.value.toFixed(2)"
unit="QQ"
variant="info"
/>
<MetricCard
label="Total QQ Oreado en Depósito"
:value="metrics.totalQqOreadoDeposito.value.toFixed(2)"
unit="QQ"
variant="info"
/>
<MetricCard
label="Total Lb Uva en Depósito"
:value="metrics.totalLbUvaDeposito.value.toFixed(2)"
unit="lb"
variant="info"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresosMetrics } from '~/composables/useIngresosMetrics'
defineProps<{
metrics: IngresosMetrics
}>()
</script>

View File

@@ -0,0 +1,46 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Inversión Restante a Realizar</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Inversión Restante Oreado"
:value="formatCurrency(metrics.inversionRestanteOreado.value)"
variant="warning"
/>
<MetricCard
label="Inversión Restante Mojado"
:value="formatCurrency(metrics.inversionRestanteMojado.value)"
variant="warning"
/>
<MetricCard
label="Inversión Restante Uva"
:value="formatCurrency(metrics.inversionRestanteUva.value)"
variant="warning"
/>
<MetricCard
label="Inversión Restante Esperada"
:value="formatCurrency(metrics.inversionRestanteEsperada.value)"
variant="danger"
class="font-bold"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresosMetrics } from '~/composables/useIngresosMetrics'
defineProps<{
metrics: IngresosMetrics
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ'
}).format(value)
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Inversión Hasta la Fecha</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Inversión en Uva"
:value="formatCurrency(metrics.inversionUva.value)"
variant="success"
/>
<MetricCard
label="Inversión en Oreado"
:value="formatCurrency(metrics.inversionOreado.value)"
variant="success"
/>
<MetricCard
label="Inversión en Mojado"
:value="formatCurrency(metrics.inversionMojado.value)"
variant="success"
/>
<MetricCard
label="Total Invertido"
:value="formatCurrency(metrics.totalInvertido.value)"
variant="primary"
class="font-bold"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresosMetrics } from '~/composables/useIngresosMetrics'
defineProps<{
metrics: IngresosMetrics
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ'
}).format(value)
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Secos Vendidos y Pérdidas</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Total QQ Secos por Vender"
:value="metrics.totalQqSecoPorVender.value.toFixed(2)"
unit="QQ"
variant="info"
/>
<MetricCard
label="Precio de Venta Promedio por QQ"
:value="formatCurrency(metrics.precioVentaPromedioPorQq.value)"
/>
<MetricCard
label="Precio de Compra Promedio por QQ"
:value="formatCurrency(metrics.precioCompraPromedioPorQq.value)"
/>
<MetricCard
label="Margen de Ganancia por QQ"
:value="formatCurrency(metrics.margenGananciaPorQq.value)"
:variant="metrics.margenGananciaPorQq.value > 0 ? 'success' : 'danger'"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresosMetrics } from '~/composables/useIngresosMetrics'
defineProps<{
metrics: IngresosMetrics
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ'
}).format(value)
}
</script>

View File

@@ -0,0 +1,45 @@
<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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<MetricCard
label="Total QQ Seco Ingresado"
:value="metrics.totalQqSecoIngresado.value.toFixed(2)"
unit="QQ"
variant="primary"
/>
<MetricCard
label="Total QQ Seco Comprado"
:value="metrics.totalQqSecoComprado.value.toFixed(2)"
unit="QQ"
variant="success"
/>
<MetricCard
label="Precio Promedio Ponderado Uva"
:value="metrics.precioPromedioUvaPorQqLb.value.toFixed(2)"
unit="$/lb"
/>
<MetricCard
label="Precio Promedio Ponderado Oreado"
:value="metrics.precioPromedioOreadoPorQq.value.toFixed(2)"
unit="$/QQ"
/>
<MetricCard
label="Precio Promedio Ponderado Mojado"
:value="metrics.precioPromedioMojadoPorQq.value.toFixed(2)"
unit="$/QQ"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresosMetrics } from '~/composables/useIngresosMetrics'
defineProps<{
metrics: IngresosMetrics
}>()
</script>

View File

@@ -0,0 +1,56 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<h2 class="text-xl font-bold brand-section-title">Totales Netos de Verde</h2>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<MetricCard
label="Total Lb Neto de Verde"
:value="metrics.totalLbNetoVerde.value.toFixed(2)"
unit="lb"
/>
<MetricCard
label="Precio Promedio Ponderado Pagado"
:value="metrics.precioPromedioVerdePagado.value.toFixed(2)"
unit="$/lb"
/>
<MetricCard
label="Total Lb Neto de Verde en Depósito"
:value="metrics.totalLbNetoVerdeDeposito.value.toFixed(2)"
unit="lb"
variant="info"
/>
<MetricCard
label="Inversión en Verde Hasta la Fecha"
:value="formatCurrency(metrics.inversionVerdeHastaFecha.value)"
variant="success"
/>
<MetricCard
label="Inversión Restante a Realizar en Verde"
:value="formatCurrency(metrics.inversionRestanteVerde.value)"
variant="warning"
/>
<MetricCard
label="Total Lb Neto Comprado de Verde"
:value="metrics.totalLbNetoCompradoVerde.value.toFixed(2)"
unit="lb"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresosMetrics } from '~/composables/useIngresosMetrics'
defineProps<{
metrics: IngresosMetrics
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ'
}).format(value)
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="bg-[#1c140c] rounded-lg p-4 border-l-4" :class="borderColor">
<h3 class="text-lg font-semibold mb-3 capitalize text-[var(--brand-text)]">{{ title }}</h3>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-[var(--brand-text-muted)] text-sm">Total {{ unidad }}:</span>
<span class="font-medium text-[var(--brand-text)]">{{ metrics.value.totalCantidad.toFixed(2) }} {{ unidad }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-[var(--brand-text-muted)] text-sm">Precio promedio:</span>
<span class="font-medium text-[var(--brand-text)]">{{ formatCurrency(metrics.value.precioPromedio) }}</span>
</div>
<div class="flex justify-between items-center pt-2 border-t border-[#3a2a16]">
<span class="text-[var(--brand-text)] font-semibold">Total cobrado:</span>
<span class="font-bold text-green-400">{{ formatCurrency(metrics.value.totalCobrado) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { RechazoMetrics } from '~/composables/useRechazosMetrics'
interface Props {
title: string
metrics: ComputedRef<RechazoMetrics>
unidad: 'libras' | 'galones'
color?: 'blue' | 'green' | 'yellow' | 'red' | 'purple' | 'pink'
}
const props = withDefaults(defineProps<Props>(), {
color: 'blue'
})
const borderColor = computed(() => {
const colors = {
blue: 'border-blue-500',
green: 'border-green-500',
yellow: 'border-yellow-500',
red: 'border-red-500',
purple: 'border-purple-500',
pink: 'border-pink-500'
}
return colors[props.color]
})
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ'
}).format(value)
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<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-green-400">{{ formatCurrency(totalRechazos.value) }}</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"
/>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { RechazosMetrics } from '~/composables/useRechazosMetrics'
const props = defineProps<{
metrics: RechazosMetrics
}>()
const totalRechazos = computed(() => props.metrics.totalRechazos)
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ'
}).format(value)
}
</script>

View File

@@ -0,0 +1,281 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
export interface IngresoRecord {
estado: 'pagado' | 'pendiente'
tipo: 'uva' | 'oreado' | 'mojado' | 'verde'
peso_seco: number
peso_neto: number
precio: number
}
export interface IngresosMetrics {
// Totales de ingreso y compra
totalQqSecoIngresado: ComputedRef<number>
totalQqSecoComprado: ComputedRef<number>
precioPromedioUvaPorQqLb: ComputedRef<number>
precioPromedioOreadoPorQq: ComputedRef<number>
precioPromedioMojadoPorQq: ComputedRef<number>
// Inversión
inversionUva: ComputedRef<number>
inversionOreado: ComputedRef<number>
inversionMojado: ComputedRef<number>
totalInvertido: ComputedRef<number>
// Inventarios en depósito
totalQqSecoDeposito: ComputedRef<number>
totalQqMojadoDeposito: ComputedRef<number>
totalQqOreadoDeposito: ComputedRef<number>
totalLbUvaDeposito: ComputedRef<number>
// Inversión restante
inversionRestanteOreado: ComputedRef<number>
inversionRestanteMojado: ComputedRef<number>
inversionRestanteUva: ComputedRef<number>
inversionRestanteEsperada: ComputedRef<number>
// Totales netos de verde
totalLbNetoVerde: ComputedRef<number>
precioPromedioVerdePagado: ComputedRef<number>
totalLbNetoVerdeDeposito: ComputedRef<number>
inversionVerdeHastaFecha: ComputedRef<number>
inversionRestanteVerde: ComputedRef<number>
totalLbNetoCompradoVerde: ComputedRef<number>
// Secos vendidos y pérdidas (placeholder)
totalQqSecoPorVender: ComputedRef<number>
precioVentaPromedioPorQq: ComputedRef<number>
precioCompraPromedioPorQq: ComputedRef<number>
margenGananciaPorQq: ComputedRef<number>
}
export function useIngresosMetrics(ingresos: ComputedRef<IngresoRecord[]>) {
// Función auxiliar para calcular total a pagar
const calcularTotalAPagar = (ingreso: IngresoRecord): number => {
if (ingreso.tipo === 'verde' || ingreso.tipo === 'uva') {
return ingreso.precio * ingreso.peso_neto
}
if (ingreso.tipo === 'oreado' || ingreso.tipo === 'mojado') {
return (ingreso.precio / 2) * ingreso.peso_seco
}
return 0
}
// TOTALES DE INGRESO Y COMPRA
const totalQqSecoIngresado = computed(() => {
return ingresos.value
.filter(i => i.estado === 'pagado' || i.estado === 'pendiente')
.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
})
const totalQqSecoComprado = computed(() => {
return ingresos.value
.filter(i => i.estado === 'pagado')
.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
})
const precioPromedioUvaPorQqLb = computed(() => {
const uvasPagadas = ingresos.value.filter(i => i.tipo === 'uva' && i.estado === 'pagado')
const sumaPesoNeto = uvasPagadas.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
if (sumaPesoNeto === 0) return 0
const sumaProducto = uvasPagadas.reduce((sum, i) =>
sum + (i.peso_neto || 0) * (i.precio || 0), 0
)
return sumaProducto / sumaPesoNeto
})
const precioPromedioOreadoPorQq = computed(() => {
const oreadosPagados = ingresos.value.filter(i => i.tipo === 'oreado' && i.estado === 'pagado')
const sumaPesoSeco = oreadosPagados.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
if (sumaPesoSeco === 0) return 0
const sumaProducto = oreadosPagados.reduce((sum, i) =>
sum + (i.peso_seco || 0) * (i.precio || 0), 0
)
return (sumaProducto / sumaPesoSeco) / 2
})
const precioPromedioMojadoPorQq = computed(() => {
const mojadosPagados = ingresos.value.filter(i => i.tipo === 'mojado' && i.estado === 'pagado')
const sumaPesoSeco = mojadosPagados.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
if (sumaPesoSeco === 0) return 0
const sumaProducto = mojadosPagados.reduce((sum, i) =>
sum + (i.peso_seco || 0) * (i.precio || 0), 0
)
return (sumaProducto / sumaPesoSeco) / 2
})
// INVERSIÓN
const inversionUva = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'uva' && i.estado === 'pagado')
.reduce((sum, i) => sum + calcularTotalAPagar(i), 0)
})
const inversionOreado = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'oreado' && i.estado === 'pagado')
.reduce((sum, i) => sum + calcularTotalAPagar(i), 0)
})
const inversionMojado = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'mojado' && i.estado === 'pagado')
.reduce((sum, i) => sum + calcularTotalAPagar(i), 0)
})
const totalInvertido = computed(() => {
return inversionUva.value + inversionOreado.value + inversionMojado.value
})
// INVENTARIOS EN DEPÓSITO
const totalQqSecoDeposito = computed(() => {
return ingresos.value
.filter(i => i.estado === 'pendiente')
.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
})
const totalQqMojadoDeposito = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'mojado' && i.estado === 'pendiente')
.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
})
const totalQqOreadoDeposito = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'oreado' && i.estado === 'pendiente')
.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
})
const totalLbUvaDeposito = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'uva' && i.estado === 'pendiente')
.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
})
// INVERSIÓN RESTANTE
const inversionRestanteOreado = computed(() => {
return precioPromedioOreadoPorQq.value * totalQqOreadoDeposito.value
})
const inversionRestanteMojado = computed(() => {
return precioPromedioMojadoPorQq.value * totalQqMojadoDeposito.value
})
const inversionRestanteUva = computed(() => {
return precioPromedioUvaPorQqLb.value * totalLbUvaDeposito.value
})
const inversionRestanteEsperada = computed(() => {
return inversionRestanteOreado.value + inversionRestanteMojado.value + inversionRestanteUva.value
})
// TOTALES NETOS DE VERDE
const totalLbNetoVerde = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'verde')
.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
})
const precioPromedioVerdePagado = computed(() => {
const verdesPagados = ingresos.value.filter(i => i.tipo === 'verde' && i.estado === 'pagado')
const sumaPesoNeto = verdesPagados.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
if (sumaPesoNeto === 0) return 0
const sumaProducto = verdesPagados.reduce((sum, i) =>
sum + (i.peso_neto || 0) * (i.precio || 0), 0
)
return sumaProducto / sumaPesoNeto
})
const totalLbNetoVerdeDeposito = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'verde' && i.estado === 'pendiente')
.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
})
const inversionVerdeHastaFecha = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'verde' && i.estado === 'pagado')
.reduce((sum, i) => sum + calcularTotalAPagar(i), 0)
})
const inversionRestanteVerde = computed(() => {
return precioPromedioVerdePagado.value * totalLbNetoVerdeDeposito.value
})
const totalLbNetoCompradoVerde = computed(() => {
return ingresos.value
.filter(i => i.tipo === 'verde' && i.estado === 'pagado')
.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
})
// SECOS VENDIDOS Y PÉRDIDAS (placeholder - necesitan datos de ventas)
const totalQqSecoPorVender = computed(() => totalQqSecoDeposito.value)
const precioVentaPromedioPorQq = computed(() => 0) // Necesita datos de ventas
const precioCompraPromedioPorQq = computed(() => {
const totalPagado = ingresos.value.filter(i => i.estado === 'pagado')
const sumaPesoSeco = totalPagado.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
if (sumaPesoSeco === 0) return 0
const sumaTotal = totalPagado.reduce((sum, i) => sum + calcularTotalAPagar(i), 0)
return sumaTotal / sumaPesoSeco
})
const margenGananciaPorQq = computed(() =>
precioVentaPromedioPorQq.value - precioCompraPromedioPorQq.value
)
return {
// Totales de ingreso y compra
totalQqSecoIngresado,
totalQqSecoComprado,
precioPromedioUvaPorQqLb,
precioPromedioOreadoPorQq,
precioPromedioMojadoPorQq,
// Inversión
inversionUva,
inversionOreado,
inversionMojado,
totalInvertido,
// Inventarios en depósito
totalQqSecoDeposito,
totalQqMojadoDeposito,
totalQqOreadoDeposito,
totalLbUvaDeposito,
// Inversión restante
inversionRestanteOreado,
inversionRestanteMojado,
inversionRestanteUva,
inversionRestanteEsperada,
// Totales netos de verde
totalLbNetoVerde,
precioPromedioVerdePagado,
totalLbNetoVerdeDeposito,
inversionVerdeHastaFecha,
inversionRestanteVerde,
totalLbNetoCompradoVerde,
// Secos vendidos y pérdidas
totalQqSecoPorVender,
precioVentaPromedioPorQq,
precioCompraPromedioPorQq,
margenGananciaPorQq
}
}

View File

@@ -0,0 +1,69 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
export interface RechazoRecord {
tipo: 'chibolita' | 'perico' | 'vano' | 'picadillo' | 'magalla' | 'pinta'
cantidad: number // libras para chibolita, perico, pinta; galones para vano, picadillo, magalla
precio_unitario: number
total_cobrado: number
}
export interface RechazoMetrics {
totalCantidad: number
precioPromedio: number
totalCobrado: number
}
export interface RechazosMetrics {
chibolita: ComputedRef<RechazoMetrics>
perico: ComputedRef<RechazoMetrics>
vano: ComputedRef<RechazoMetrics>
picadillo: ComputedRef<RechazoMetrics>
magalla: ComputedRef<RechazoMetrics>
pinta: ComputedRef<RechazoMetrics>
totalRechazos: ComputedRef<number>
}
export function useRechazosMetrics(rechazos: ComputedRef<RechazoRecord[]>) {
const calcularMetricasPorTipo = (tipo: RechazoRecord['tipo']): ComputedRef<RechazoMetrics> => {
return computed(() => {
const registros = rechazos.value.filter(r => r.tipo === tipo)
const totalCantidad = registros.reduce((sum, r) => sum + (r.cantidad || 0), 0)
const totalCobrado = registros.reduce((sum, r) => sum + (r.total_cobrado || 0), 0)
const precioPromedio = totalCantidad > 0 ? totalCobrado / totalCantidad : 0
return {
totalCantidad,
precioPromedio,
totalCobrado
}
})
}
const chibolita = calcularMetricasPorTipo('chibolita')
const perico = calcularMetricasPorTipo('perico')
const vano = calcularMetricasPorTipo('vano')
const picadillo = calcularMetricasPorTipo('picadillo')
const magalla = calcularMetricasPorTipo('magalla')
const pinta = calcularMetricasPorTipo('pinta')
const totalRechazos = computed(() => {
return chibolita.value.totalCobrado +
perico.value.totalCobrado +
vano.value.totalCobrado +
picadillo.value.totalCobrado +
magalla.value.totalCobrado +
pinta.value.totalCobrado
})
return {
chibolita,
perico,
vano,
picadillo,
magalla,
pinta,
totalRechazos
}
}

View File

@@ -118,6 +118,12 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
to: '/', to: '/',
active: route.path === '/' active: route.path === '/'
}, },
{
label: 'Panorama Facturador',
icon: 'i-lucide-bar-chart-3',
to: '/panorama',
active: route.path === '/panorama'
},
{ {
label: 'Explorador de datos', label: 'Explorador de datos',
icon: 'i-lucide-table', icon: 'i-lucide-table',

View File

@@ -0,0 +1,249 @@
<template>
<div class="flex flex-col gap-8">
<!-- Loading State -->
<UCard v-if="loading && !ingresosStore.hasData" 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>
<UProgress
v-if="loadingProgress > 0"
:model-value="loadingProgress"
:max="100"
size="sm"
class="w-64"
/>
<span v-if="loadingProgress > 0" class="text-xs text-[var(--brand-text-muted)]">
{{ Math.round(loadingProgress) }}%
</span>
</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>
</div>
<!-- Main Content -->
<template v-else>
<!-- Metadatos Cards de Ingresos y Rechazos -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
<MetadatosCard v-if="rechazosMetadata" :metadata="rechazosMetadata" />
</div>
<!-- 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="refreshing"
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
size="sm"
@click="refreshData"
>
<template #leading>
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': refreshing }" />
</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(ingresosMetrics.totalInvertido.value + ingresosMetrics.inversionVerdeHastaFecha.value) }}
</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(rechazosMetrics.totalRechazos.value) }}
</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(ingresosMetrics.totalInvertido.value + ingresosMetrics.inversionVerdeHastaFecha.value - rechazosMetrics.totalRechazos.value) }}
</div>
</div>
</div>
<template #footer>
<div class="text-xs text-[var(--brand-text-muted)]">
Última actualización: {{ lastUpdated }}
</div>
</template>
</UCard>
<!-- Ingresos Sections -->
<IngresosTotalesIngresoCompra :metrics="ingresosMetrics" />
<IngresosInversionTotal :metrics="ingresosMetrics" />
<IngresosInventariosDeposito :metrics="ingresosMetrics" />
<IngresosInversionRestante :metrics="ingresosMetrics" />
<IngresosTotalesVerde :metrics="ingresosMetrics" />
<IngresosSecosVendidos :metrics="ingresosMetrics" />
<!-- Rechazos Section -->
<RechazosSubproductos :metrics="rechazosMetrics" />
</template>
</div>
</template>
<script setup lang="ts">
import { useTableDataStore } from '~/stores/tableDataFactory'
import { useMetadataStore } from '~/stores/metadata'
import { useIngresosMetrics } from '~/composables/useIngresosMetrics'
import { useRechazosMetrics } from '~/composables/useRechazosMetrics'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
import type { RechazoRecord } from '~/composables/useRechazosMetrics'
// Define page metadata
definePageMeta({
layout: 'dashboard',
title: 'Panorama Facturador'
})
// Initialize stores
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
const rechazosStore = useTableDataStore<RechazoRecord>('rechazos')
// Reactive data from stores
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
const rechazos = computed(() => rechazosStore.allRecords as RechazoRecord[])
// Calculate metrics using composables
const ingresosMetrics = useIngresosMetrics(ingresos)
const rechazosMetrics = useRechazosMetrics(rechazos)
// Loading and error states
const loading = computed(() => ingresosStore.isLoading || rechazosStore.isLoading)
const error = computed(() => ingresosStore.error || rechazosStore.error)
const refreshing = ref(false)
const loadingProgress = ref(0)
// Last updated time
const lastUpdated = computed(() => {
const ingresosDate = ingresosStore.lastUpdated
const rechazosDate = rechazosStore.lastUpdated
if (!ingresosDate && !rechazosDate) return 'Nunca'
const latest = [ingresosDate, rechazosDate]
.filter(Boolean)
.sort()
.reverse()[0]
if (!latest) return 'Nunca'
return new Date(latest).toLocaleString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
})
// Format currency helper
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
// Metadatos desde el store de metadata
const metadataStore = useMetadataStore()
const ingresosMetadata = computed(() => {
// Buscar por el nombre de la vista
const meta = metadataStore.allTables.find(t => t.table === 'vista_detalle_ingresos')
return meta ? {
...meta,
name: 'ingresos'
} : null
})
const rechazosMetadata = computed(() => {
// Buscar por el nombre de la tabla de rechazos
const meta = metadataStore.allTables.find(t => t.table === 'rechazos')
return meta ? {
...meta,
name: 'rechazos'
} : null
})
// Refresh data
async function refreshData() {
refreshing.value = true
loadingProgress.value = 0
try {
let ingresosProgress = 0
let rechazosProgress = 0
await Promise.all([
ingresosStore.loadAllDataInBatches((progress) => {
ingresosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
}),
rechazosStore.loadAllDataInBatches((progress) => {
rechazosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
})
])
} catch (err) {
console.error('Error refreshing data:', err)
} finally {
refreshing.value = false
loadingProgress.value = 0
}
}
// Load data on mount
onMounted(async () => {
try {
// Cargar metadatos primero
if (!metadataStore.hasMetadata) {
await metadataStore.loadMetadata()
}
// Primero cargamos del cache para mostrar datos inmediatamente
await Promise.all([
ingresosStore.loadFromCache(),
rechazosStore.loadFromCache()
])
// Si no hay datos en cache o están desactualizados, cargamos todos los datos
if (!ingresosStore.hasData || !rechazosStore.hasData) {
loadingProgress.value = 0
let ingresosProgress = 0
let rechazosProgress = 0
await Promise.all([
ingresosStore.loadAllDataInBatches((progress) => {
ingresosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
}),
rechazosStore.loadAllDataInBatches((progress) => {
rechazosProgress = progress
loadingProgress.value = (ingresosProgress + rechazosProgress) / 2
})
])
loadingProgress.value = 0
}
} catch (err) {
console.error('Error loading data:', err)
}
})
</script>