504 lines
18 KiB
Vue
504 lines
18 KiB
Vue
<!-- nuxt4-app/app/pages/panorama.vue -->
|
|
<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>
|
|
|
|
<!-- 🔻 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</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>
|
|
|
|
<div class="flex flex-col md:flex-row gap-4">
|
|
<!-- Presets -->
|
|
<div class="flex-1">
|
|
<label class="text-xs text-[var(--brand-text-muted)] block mb-1">Rango rápido</label>
|
|
<UFieldGroup>
|
|
<UButton
|
|
color="neutral"
|
|
variant="subtle"
|
|
:label="currentPresetLabel"
|
|
class="flex-1"
|
|
disabled
|
|
/>
|
|
<UDropdownMenu :items="dropdownItems" :popper="{ placement: 'bottom-end' }">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-chevron-down"
|
|
square
|
|
/>
|
|
</UDropdownMenu>
|
|
</UFieldGroup>
|
|
</div>
|
|
|
|
<!-- Fechas manuales -->
|
|
<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" @input="onManualDateChange" />
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-[var(--brand-text-muted)]">Fecha hasta</label>
|
|
<UInput v-model="fechaHasta" type="date" @input="onManualDateChange" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-end">
|
|
<UButton
|
|
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
|
@click="clearPreset"
|
|
size="sm"
|
|
>
|
|
Limpiar
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="text-xs text-[var(--brand-text-muted)]">
|
|
Rango activo: {{ rangoLegible }} · Registros considerados: Ingresos {{ ingresosFiltrados.length }}/{{ ingresos.length }} · Rechazos {{ rechazosFiltrados.length }}/{{ rechazos.length }}
|
|
</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="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 -->
|
|
<IngresosPreciosPromedio :metrics="ingresosMetrics" />
|
|
<IngresosTotalesIngresoCompra :metrics="ingresosMetrics" />
|
|
<IngresosInversionTotal :metrics="ingresosMetrics" />
|
|
<IngresosSecosVendidos :metrics="ingresosMetrics" />
|
|
<IngresosInversionRestante :metrics="ingresosMetrics" />
|
|
<IngresosTotalesVerde :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 (sin filtrar)
|
|
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
|
|
const rechazos = computed(() => rechazosStore.allRecords as RechazoRecord[])
|
|
|
|
// -------------------------------
|
|
// 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 currentPresetLabel = computed(() => {
|
|
switch (selectedPreset.value) {
|
|
case '': return 'Sin filtro'
|
|
case 'custom': return 'Personalizado'
|
|
case 'hoy': return 'Hoy'
|
|
case 'semana': return 'Esta Semana'
|
|
case 'mes': return 'Este Mes'
|
|
case 'ytd': return 'YTD'
|
|
case 'cosecha-20-21': return 'Cosecha 20-21'
|
|
case 'cosecha-21-22': return 'Cosecha 21-22'
|
|
case 'cosecha-22-23': return 'Cosecha 22-23'
|
|
case 'cosecha-23-24': return 'Cosecha 23-24'
|
|
case 'cosecha-24-25': return 'Cosecha 24-25'
|
|
case 'cosecha-25-26': return 'Cosecha 25-26'
|
|
default: return 'Seleccionar rango'
|
|
}
|
|
})
|
|
|
|
const dropdownItems = [
|
|
{
|
|
label: 'Sin filtro',
|
|
onSelect: () => {
|
|
selectPreset('')
|
|
}
|
|
},
|
|
{
|
|
label: 'Rápidos',
|
|
children: [
|
|
{ label: 'Hoy', onSelect: () => { selectPreset('hoy') } },
|
|
{ label: 'Esta Semana', onSelect: () => { selectPreset('semana') } },
|
|
{ label: 'Este Mes', onSelect: () => { selectPreset('mes') } },
|
|
{ label: 'YTD', onSelect: () => { selectPreset('ytd') } }
|
|
]
|
|
},
|
|
{
|
|
label: 'Cosechas',
|
|
children: [
|
|
{ label: 'Cosecha 20-21 (25 Sep 2020)', onSelect: () => { selectPreset('cosecha-20-21') } },
|
|
{ label: 'Cosecha 21-22 (25 Sep 2021)', onSelect: () => { selectPreset('cosecha-21-22') } },
|
|
{ label: 'Cosecha 22-23 (25 Sep 2022)', onSelect: () => { selectPreset('cosecha-22-23') } },
|
|
{ label: 'Cosecha 23-24 (25 Sep 2023)', onSelect: () => { selectPreset('cosecha-23-24') } },
|
|
{ label: 'Cosecha 24-25 (25 Sep 2024)', onSelect: () => { selectPreset('cosecha-24-25') } },
|
|
{ label: 'Cosecha 25-26 (10 Sep 2025 → hoy)', onSelect: () => { selectPreset('cosecha-25-26') } }
|
|
]
|
|
}
|
|
]
|
|
|
|
// Fechas (YYYY-MM-DD) — Honduras (UTC-6)
|
|
const toLocalDateStr = (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<string | null>(null)
|
|
const fechaHasta = ref<string | null>(null)
|
|
|
|
function selectPreset(preset: PresetValue) {
|
|
console.log('selectPreset called with:', preset)
|
|
selectedPreset.value = preset
|
|
|
|
if (preset === '' || preset === 'custom') {
|
|
fechaDesde.value = null
|
|
fechaHasta.value = null
|
|
console.log('Cleared dates')
|
|
return
|
|
}
|
|
|
|
const now = new Date()
|
|
const set = (sd: string, ed: string) => {
|
|
fechaDesde.value = sd
|
|
fechaHasta.value = ed
|
|
console.log('Set dates:', sd, ed)
|
|
}
|
|
|
|
switch (preset) {
|
|
case 'hoy': set(toLocalDateStr(now), toLocalDateStr(now)); break
|
|
case 'semana': {
|
|
const d = new Date(now)
|
|
const day = d.getDay() || 7
|
|
d.setDate(d.getDate() - (day - 1)) // lunes
|
|
set(toLocalDateStr(d), toLocalDateStr(now))
|
|
break
|
|
}
|
|
case 'mes': set(toLocalDateStr(new Date(now.getFullYear(), now.getMonth(), 1)), toLocalDateStr(now)); break
|
|
case 'ytd': set(toLocalDateStr(new Date(now.getFullYear(), 0, 1)), toLocalDateStr(now)); break
|
|
case 'cosecha-20-21': set('2020-09-25', '2021-09-24'); break
|
|
case 'cosecha-21-22': set('2021-09-25', '2022-09-24'); break
|
|
case 'cosecha-22-23': set('2022-09-25', '2023-09-24'); break
|
|
case 'cosecha-23-24': set('2023-09-25', '2024-09-24'); break
|
|
case 'cosecha-24-25': set('2024-09-25', '2025-09-09'); break
|
|
case 'cosecha-25-26': set('2025-09-10', toLocalDateStr(now)); break
|
|
}
|
|
}
|
|
|
|
function onManualDateChange() {
|
|
// Si el usuario modifica las fechas manualmente, cambiar a "Personalizado"
|
|
selectedPreset.value = 'custom'
|
|
console.log('Manual date change, preset set to custom')
|
|
}
|
|
|
|
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
|
|
console.log('User cancelled including anulados')
|
|
} else {
|
|
console.log('User confirmed including anulados')
|
|
}
|
|
} else {
|
|
// Al desactivar, no pedir confirmación
|
|
console.log('Anulados disabled')
|
|
}
|
|
}
|
|
|
|
function clearPreset() {
|
|
selectedPreset.value = ''
|
|
fechaDesde.value = null
|
|
fechaHasta.value = null
|
|
console.log('Preset cleared')
|
|
}
|
|
|
|
const rangoLegible = computed(() => {
|
|
if (!fechaDesde.value && !fechaHasta.value) return 'Sin filtro de fecha'
|
|
const f = fechaDesde.value ?? '—'
|
|
const t = fechaHasta.value ?? '—'
|
|
return `${f} → ${t}`
|
|
})
|
|
|
|
function isAnulado(row: any): boolean {
|
|
const estado = (row?.estado ?? '').toString().toLowerCase()
|
|
const fechaAn = row?.fecha_anulado ?? null
|
|
return estado === 'anulado' || !!fechaAn
|
|
}
|
|
|
|
function isWithinDate(row: any, from?: string | null, to?: string | null): boolean {
|
|
const created = row?.created_at ? new Date(row.created_at) : null
|
|
if (!created || isNaN(created.getTime())) return false
|
|
if (from) {
|
|
const fd = new Date(from + 'T00:00:00-06:00')
|
|
if (created < fd) return false
|
|
}
|
|
if (to) {
|
|
const td = new Date(to + 'T23:59:59-06:00')
|
|
if (created > td) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Filtrados que alimentan los métricos
|
|
const ingresosFiltrados = computed(() => {
|
|
return (ingresos.value ?? [])
|
|
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
|
|
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
|
|
})
|
|
|
|
const rechazosFiltrados = computed(() => {
|
|
return (rechazos.value ?? [])
|
|
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
|
|
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
|
|
})
|
|
|
|
// Métricos basados en filtrados
|
|
const ingresosMetrics = useIngresosMetrics(ingresosFiltrados)
|
|
const rechazosMetrics = useRechazosMetrics(rechazosFiltrados)
|
|
|
|
// 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] as string | undefined
|
|
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(() => {
|
|
const meta = metadataStore.metadata.find((t: any) => t.table === 'vista_detalle_ingresos')
|
|
return meta ? { ...meta, name: 'ingresos' } : null
|
|
})
|
|
|
|
const rechazosMetadata = computed(() => {
|
|
const meta = metadataStore.metadata.find((t: any) => 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 + default preset
|
|
onMounted(async () => {
|
|
try {
|
|
// Cargar metadatos primero
|
|
if (metadataStore.metadata.length === 0) {
|
|
await (metadataStore as any).loadMetadata()
|
|
}
|
|
|
|
// Cache primero para UX
|
|
await Promise.all([
|
|
ingresosStore.loadFromCache(),
|
|
rechazosStore.loadFromCache()
|
|
])
|
|
|
|
// Si falta data, cargar en lotes
|
|
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)
|
|
} finally {
|
|
// Default preset: cosecha 25-26
|
|
selectPreset('cosecha-25-26')
|
|
includeAnulados.value = false
|
|
}
|
|
})
|
|
</script>
|