pagina panorama facturador en camino
This commit is contained in:
41
nuxt4-app/app/components/MetricCard.vue
Normal file
41
nuxt4-app/app/components/MetricCard.vue
Normal 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>
|
||||
42
nuxt4-app/app/components/ingresos/InventariosDeposito.vue
Normal file
42
nuxt4-app/app/components/ingresos/InventariosDeposito.vue
Normal 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>
|
||||
46
nuxt4-app/app/components/ingresos/InversionRestante.vue
Normal file
46
nuxt4-app/app/components/ingresos/InversionRestante.vue
Normal 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>
|
||||
46
nuxt4-app/app/components/ingresos/InversionTotal.vue
Normal file
46
nuxt4-app/app/components/ingresos/InversionTotal.vue
Normal 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>
|
||||
44
nuxt4-app/app/components/ingresos/SecosVendidos.vue
Normal file
44
nuxt4-app/app/components/ingresos/SecosVendidos.vue
Normal 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>
|
||||
45
nuxt4-app/app/components/ingresos/TotalesIngresoCompra.vue
Normal file
45
nuxt4-app/app/components/ingresos/TotalesIngresoCompra.vue
Normal 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>
|
||||
56
nuxt4-app/app/components/ingresos/TotalesVerde.vue
Normal file
56
nuxt4-app/app/components/ingresos/TotalesVerde.vue
Normal 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>
|
||||
53
nuxt4-app/app/components/rechazos/RechazoCard.vue
Normal file
53
nuxt4-app/app/components/rechazos/RechazoCard.vue
Normal 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>
|
||||
69
nuxt4-app/app/components/rechazos/RechazosSubproductos.vue
Normal file
69
nuxt4-app/app/components/rechazos/RechazosSubproductos.vue
Normal 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>
|
||||
281
nuxt4-app/app/composables/useIngresosMetrics.ts
Normal file
281
nuxt4-app/app/composables/useIngresosMetrics.ts
Normal 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
|
||||
}
|
||||
}
|
||||
69
nuxt4-app/app/composables/useRechazosMetrics.ts
Normal file
69
nuxt4-app/app/composables/useRechazosMetrics.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,12 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
||||
to: '/',
|
||||
active: route.path === '/'
|
||||
},
|
||||
{
|
||||
label: 'Panorama Facturador',
|
||||
icon: 'i-lucide-bar-chart-3',
|
||||
to: '/panorama',
|
||||
active: route.path === '/panorama'
|
||||
},
|
||||
{
|
||||
label: 'Explorador de datos',
|
||||
icon: 'i-lucide-table',
|
||||
|
||||
249
nuxt4-app/app/pages/panorama.vue
Normal file
249
nuxt4-app/app/pages/panorama.vue
Normal 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>
|
||||
Reference in New Issue
Block a user