panorama facturador mejoras UI/UX

This commit is contained in:
2025-09-30 13:44:12 -06:00
parent b171fbdb21
commit a5941c4a43
10 changed files with 289 additions and 64 deletions

View File

@@ -6,21 +6,21 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Total QQ Seco en Depósito"
label="Total qq Seco en Depósito"
:value="metrics.totalQqSecoDeposito.value.toFixed(2)"
unit="QQ"
unit="qq"
variant="warning"
/>
<MetricCard
label="Total QQ Mojado en Depósito"
label="Total qq Mojado en Depósito"
:value="metrics.totalQqMojadoDeposito.value.toFixed(2)"
unit="QQ"
unit="qq"
variant="info"
/>
<MetricCard
label="Total QQ Oreado en Depósito"
label="Total qq Oreado en Depósito"
:value="metrics.totalQqOreadoDeposito.value.toFixed(2)"
unit="QQ"
unit="qq"
variant="info"
/>
<MetricCard

View File

@@ -38,9 +38,11 @@ defineProps<{
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'GTQ'
}).format(value)
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -38,9 +38,11 @@ defineProps<{
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'GTQ'
}).format(value)
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -36,9 +36,11 @@ defineProps<{
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'GTQ'
}).format(value)
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -6,31 +6,31 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<MetricCard
label="Total QQ Seco Ingresado"
label="Total qq Seco Ingresado"
:value="metrics.totalQqSecoIngresado.value.toFixed(2)"
unit="QQ"
unit="qq"
variant="primary"
/>
<MetricCard
label="Total QQ Seco Comprado"
label="Total qq Seco Comprado"
:value="metrics.totalQqSecoComprado.value.toFixed(2)"
unit="QQ"
unit="qq"
variant="success"
/>
<MetricCard
label="Precio Promedio Ponderado Uva"
:value="metrics.precioPromedioUvaPorQqLb.value.toFixed(2)"
unit="$/lb"
unit="L./lb"
/>
<MetricCard
label="Precio Promedio Ponderado Oreado"
:value="metrics.precioPromedioOreadoPorQq.value.toFixed(2)"
unit="$/QQ"
unit="L./qq"
/>
<MetricCard
label="Precio Promedio Ponderado Mojado"
:value="metrics.precioPromedioMojadoPorQq.value.toFixed(2)"
unit="$/QQ"
unit="L./qq"
/>
</div>
</UCard>

View File

@@ -13,7 +13,7 @@
<MetricCard
label="Precio Promedio Ponderado Pagado"
:value="metrics.precioPromedioVerdePagado.value.toFixed(2)"
unit="$/lb"
unit="L./lb"
/>
<MetricCard
label="Total Lb Neto de Verde en Depósito"
@@ -48,9 +48,11 @@ defineProps<{
}>()
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'GTQ'
}).format(value)
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -45,9 +45,11 @@ const borderColor = computed(() => {
})
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'GTQ'
}).format(value)
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -61,9 +61,11 @@ const props = defineProps<{
const totalRechazos = computed(() => props.metrics.totalRechazos)
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('es-GT', {
return new Intl.NumberFormat('es-HN', {
style: 'currency',
currency: 'GTQ'
}).format(value)
currency: 'HNL',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(value).replace('HNL', 'L')
}
</script>

View File

@@ -30,8 +30,10 @@ export function useRechazosMetrics(rechazos: ComputedRef<RechazoRecord[]>) {
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
// const totalCobrado = registros.reduce((sum, r) => sum + (r.total_cobrado || 0), 0) DESACTIVADO HASTA NORMALIZAR LOS DATOS DE LA TABLA ORIGINAL, MENCIONAR SI TE TOPAS CON ESTO UN MES DESPUES ESTAMOS EN 1 OCTUBRE 2025
// const precioPromedio = totalCantidad > 0 ? totalCobrado / totalCantidad : 0 DESACTIVADO HASTA NORMALIZAR LOS DATOS DE LA TABLA ORIGINAL, MENCIONAR SI TE TOPAS CON ESTO UN MES DESPUES ESTAMOS EN 1 OCTUBRE 2025
const precioPromedio = 10
const totalCobrado = 100
return {
totalCantidad,

View File

@@ -1,3 +1,4 @@
<!-- nuxt4-app/app/pages/panorama.vue -->
<template>
<div class="flex flex-col gap-8">
<!-- Loading State -->
@@ -33,6 +34,74 @@
<MetadatosCard v-if="rechazosMetadata" :metadata="rechazosMetadata" />
</div>
<!-- 🔻 Card de Filtros -->
<UCard class="brand-card border border-transparent">
<template #header>
<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">
<UToggle v-model="includeAnulados" />
<span class="text-sm">Incluir anulados</span>
</div>
</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"
/>
<UDropdownMenu :items="dropdownItems">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-down"
/>
</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>
@@ -115,13 +184,166 @@ definePageMeta({
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
const rechazosStore = useTableDataStore<RechazoRecord>('rechazos')
// Reactive data from stores
// Reactive data from stores (sin filtrar)
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)
// -------------------------------
// 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 = computed(() => [
{
label: 'Sin filtro',
click: () => selectPreset('')
},
{
label: 'Rápidos',
children: [
{ label: 'Hoy', click: () => selectPreset('hoy') },
{ label: 'Esta Semana', click: () => selectPreset('semana') },
{ label: 'Este Mes', click: () => selectPreset('mes') },
{ label: 'YTD', click: () => selectPreset('ytd') }
]
},
{
label: 'Cosechas',
children: [
{ label: 'Cosecha 20-21 (25 Sep 2020)', click: () => selectPreset('cosecha-20-21') },
{ label: 'Cosecha 21-22 (25 Sep 2021)', click: () => selectPreset('cosecha-21-22') },
{ label: 'Cosecha 22-23 (25 Sep 2022)', click: () => selectPreset('cosecha-22-23') },
{ label: 'Cosecha 23-24 (25 Sep 2023)', click: () => selectPreset('cosecha-23-24') },
{ label: 'Cosecha 24-25 (25 Sep 2024)', click: () => selectPreset('cosecha-24-25') },
{ label: 'Cosecha 25-26 (10 Sep 2025 → hoy)', click: () => 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) {
selectedPreset.value = preset
if (preset === '' || preset === 'custom') {
fechaDesde.value = null
fechaHasta.value = null
return
}
const now = new Date()
const set = (sd: string | null, ed: string | null) => {
fechaDesde.value = sd
fechaHasta.value = 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'
}
function clearPreset() {
selectedPreset.value = ''
fechaDesde.value = null
fechaHasta.value = null
}
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)
@@ -133,16 +355,9 @@ const loadingProgress = ref(0)
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]
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',
@@ -166,21 +381,13 @@ const formatCurrency = (value: number) => {
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 meta = metadataStore.metadata.find((t: any) => 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
const meta = metadataStore.metadata.find((t: any) => t.table === 'rechazos')
return meta ? { ...meta, name: 'rechazos' } : null
})
// Refresh data
@@ -209,21 +416,21 @@ async function refreshData() {
}
}
// Load data on mount
// Load data on mount + default preset
onMounted(async () => {
try {
// Cargar metadatos primero
if (!metadataStore.hasMetadata) {
await metadataStore.loadMetadata()
if (metadataStore.metadata.length === 0) {
await metadataStore.fetchMetadata()
}
// Primero cargamos del cache para mostrar datos inmediatamente
// Cache primero para UX
await Promise.all([
ingresosStore.loadFromCache(),
rechazosStore.loadFromCache()
])
// Si no hay datos en cache o están desactualizados, cargamos todos los datos
// Si falta data, cargar en lotes
if (!ingresosStore.hasData || !rechazosStore.hasData) {
loadingProgress.value = 0
let ingresosProgress = 0
@@ -244,6 +451,10 @@ onMounted(async () => {
}
} catch (err) {
console.error('Error loading data:', err)
} finally {
// Default preset: cosecha 25-26
selectPreset('cosecha-25-26')
includeAnulados.value = false
}
})
</script>
</script>