panorama facturador mejoras UI/UX
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user