cambio heavys
This commit is contained in:
138
nuxt4-app/app/components/app/AppSidebar.vue
Normal file
138
nuxt4-app/app/components/app/AppSidebar.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<UDashboardSidebar
|
||||||
|
v-model:open="open"
|
||||||
|
v-model:collapsed="collapsed"
|
||||||
|
collapsible
|
||||||
|
resizable
|
||||||
|
:default-size="28"
|
||||||
|
:min-size="20"
|
||||||
|
:max-size="38"
|
||||||
|
:toggle="{ color: 'primary', variant: 'subtle', class: 'rounded-full' }"
|
||||||
|
>
|
||||||
|
<template #header="{ collapsed: isCollapsed }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
v-if="!isCollapsed"
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Analítica Núcleo"
|
||||||
|
class="h-8 w-8 rounded-full border border-[#ffe0a0]/40"
|
||||||
|
/>
|
||||||
|
<UIcon v-else name="i-lucide-activity" class="size-5 text-[#ffe0a0]" />
|
||||||
|
<span v-if="!isCollapsed" class="text-sm font-semibold text-[var(--brand-text)]">Analítica Núcleo</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="{ collapsed: isCollapsed }">
|
||||||
|
<UButton
|
||||||
|
:label="isCollapsed ? undefined : 'Buscar...'"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
block
|
||||||
|
:square="isCollapsed"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template v-if="!isCollapsed" #trailing>
|
||||||
|
<div class="flex items-center gap-0.5 ms-auto text-[var(--brand-text-muted)]">
|
||||||
|
<UKbd value="⌘" variant="subtle" />
|
||||||
|
<UKbd value="K" variant="subtle" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UNavigationMenu
|
||||||
|
:collapsed="isCollapsed"
|
||||||
|
:items="navigationPrimary"
|
||||||
|
orientation="vertical"
|
||||||
|
class="gap-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UNavigationMenu
|
||||||
|
:collapsed="isCollapsed"
|
||||||
|
:items="navigationSecondary"
|
||||||
|
orientation="vertical"
|
||||||
|
class="mt-auto gap-1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer="{ collapsed: isCollapsed }">
|
||||||
|
<UButton
|
||||||
|
:avatar="{ src: 'https://avatars.githubusercontent.com/u/12011070?v=4' }"
|
||||||
|
:label="isCollapsed ? undefined : 'Equipo Núcleo'"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
:block="isCollapsed"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<UIcon name="i-lucide-log-out" class="size-4" />
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UDashboardSidebar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: true })
|
||||||
|
const collapsed = defineModel<boolean>('collapsed', { default: false })
|
||||||
|
|
||||||
|
const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: 'Inicio',
|
||||||
|
icon: 'i-lucide-home',
|
||||||
|
to: '/',
|
||||||
|
active: route.path === '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Panorama Facturador',
|
||||||
|
icon: 'i-lucide-bar-chart-3',
|
||||||
|
to: '/panorama',
|
||||||
|
active: route.path === '/panorama'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Informe Ingresos',
|
||||||
|
icon: 'i-lucide-file-bar-chart',
|
||||||
|
to: '/informe-ingresos',
|
||||||
|
active: route.path === '/informe-ingresos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Explorador de datos',
|
||||||
|
icon: 'i-lucide-table',
|
||||||
|
to: '/explorer',
|
||||||
|
active: route.path === '/explorer'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Metadatos',
|
||||||
|
icon: 'i-lucide-database',
|
||||||
|
to: '/metadatos',
|
||||||
|
active: route.path === '/metadatos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Explorador de datos raw',
|
||||||
|
icon: 'i-lucide-table',
|
||||||
|
to: '/rawExplorer',
|
||||||
|
active: route.path === '/rawExplorer'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const navigationSecondary: NavigationMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Documentación',
|
||||||
|
icon: 'i-lucide-book-open',
|
||||||
|
to: 'https://ui.nuxt.com',
|
||||||
|
target: '_blank'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Repositorio',
|
||||||
|
icon: 'i-lucide-github',
|
||||||
|
to: 'https://gitea.nucleoriofrio.com/nucleo000/analiticaNucleo',
|
||||||
|
target: '_blank'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
203
nuxt4-app/app/components/informe-ingresos/FiltrosPanel.vue
Normal file
203
nuxt4-app/app/components/informe-ingresos/FiltrosPanel.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Fila 1: Selector de Clientes -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
|
||||||
|
Clientes
|
||||||
|
</h3>
|
||||||
|
<ClienteSelector
|
||||||
|
:clientes="clientes"
|
||||||
|
:selected-ids="selectedClienteIds"
|
||||||
|
@update:selected-ids="emit('update:selectedClienteIds', $event)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="selectedClienteIds.length > 0"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
@click="emit('update:selectedClienteIds', [])"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-x" />
|
||||||
|
</template>
|
||||||
|
Limpiar selección
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 2: Selector de Rango de Fechas -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
|
||||||
|
Rango de Fechas
|
||||||
|
</h3>
|
||||||
|
<DateRangeSelector
|
||||||
|
:selected-preset="selectedPreset"
|
||||||
|
:fecha-desde="fechaDesde"
|
||||||
|
:fecha-hasta="fechaHasta"
|
||||||
|
@update:selected-preset="emit('update:selectedPreset', $event)"
|
||||||
|
@update:fecha-desde="emit('update:fechaDesde', $event)"
|
||||||
|
@update:fecha-hasta="emit('update:fechaHasta', $event)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="fechaDesde || fechaHasta"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
@click="clearDates"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-x" />
|
||||||
|
</template>
|
||||||
|
Limpiar fechas
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 3: Filtros Avanzados (grid de 4 columnas) -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
|
||||||
|
Filtros Avanzados
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<!-- Tipo de Café -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-[var(--brand-text-muted)]">Tipo</label>
|
||||||
|
<UInputMenu
|
||||||
|
v-model="selectedTipos"
|
||||||
|
:items="tiposOptions"
|
||||||
|
value-key="value"
|
||||||
|
multiple
|
||||||
|
placeholder="Todos"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-coffee"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estado -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-[var(--brand-text-muted)]">Estado</label>
|
||||||
|
<UInputMenu
|
||||||
|
v-model="selectedEstados"
|
||||||
|
:items="estadosOptions"
|
||||||
|
value-key="value"
|
||||||
|
multiple
|
||||||
|
placeholder="Todos"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-check-circle"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ubicación -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-[var(--brand-text-muted)]">Ubicación</label>
|
||||||
|
<UInputMenu
|
||||||
|
v-model="selectedUbicaciones"
|
||||||
|
:items="ubicacionesOptions"
|
||||||
|
value-key="value"
|
||||||
|
multiple
|
||||||
|
placeholder="Todas"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-map-pin"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calidad -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs text-[var(--brand-text-muted)]">Calidad</label>
|
||||||
|
<UInputMenu
|
||||||
|
v-model="selectedCalidades"
|
||||||
|
:items="calidadesOptions"
|
||||||
|
value-key="value"
|
||||||
|
multiple
|
||||||
|
placeholder="Todas"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-star"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkbox de incluir anulados -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<UCheckbox v-model="includeAnulados" label="Incluir anulados" size="sm" />
|
||||||
|
<UAlert
|
||||||
|
v-if="includeAnulados"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-alert-triangle"
|
||||||
|
title="Incluir anulados activado"
|
||||||
|
description="Los cálculos incluyen registros anulados."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Cliente } from '~/composables/useClienteSelector'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clientes: Cliente[]
|
||||||
|
selectedClienteIds: number[]
|
||||||
|
selectedPreset: string
|
||||||
|
fechaDesde: string | null
|
||||||
|
fechaHasta: string | null
|
||||||
|
selectedTipos: string[]
|
||||||
|
selectedEstados: string[]
|
||||||
|
selectedUbicaciones: string[]
|
||||||
|
selectedCalidades: string[]
|
||||||
|
tiposOptions: any[]
|
||||||
|
estadosOptions: any[]
|
||||||
|
ubicacionesOptions: any[]
|
||||||
|
calidadesOptions: any[]
|
||||||
|
includeAnulados: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedClienteIds': [value: number[]]
|
||||||
|
'update:selectedPreset': [value: string]
|
||||||
|
'update:fechaDesde': [value: string | null]
|
||||||
|
'update:fechaHasta': [value: string | null]
|
||||||
|
'update:selectedTipos': [value: string[]]
|
||||||
|
'update:selectedEstados': [value: string[]]
|
||||||
|
'update:selectedUbicaciones': [value: string[]]
|
||||||
|
'update:selectedCalidades': [value: string[]]
|
||||||
|
'update:includeAnulados': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedTipos = computed({
|
||||||
|
get: () => props.selectedTipos,
|
||||||
|
set: (value) => emit('update:selectedTipos', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedEstados = computed({
|
||||||
|
get: () => props.selectedEstados,
|
||||||
|
set: (value) => emit('update:selectedEstados', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedUbicaciones = computed({
|
||||||
|
get: () => props.selectedUbicaciones,
|
||||||
|
set: (value) => emit('update:selectedUbicaciones', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedCalidades = computed({
|
||||||
|
get: () => props.selectedCalidades,
|
||||||
|
set: (value) => emit('update:selectedCalidades', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const includeAnulados = computed({
|
||||||
|
get: () => props.includeAnulados,
|
||||||
|
set: (value) => emit('update:includeAnulados', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearDates() {
|
||||||
|
emit('update:fechaDesde', null)
|
||||||
|
emit('update:fechaHasta', null)
|
||||||
|
emit('update:selectedPreset', '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
178
nuxt4-app/app/components/informe-ingresos/MetadatosPanel.vue
Normal file
178
nuxt4-app/app/components/informe-ingresos/MetadatosPanel.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- Versión ultra compacta de MetadatosCard -->
|
||||||
|
<div v-if="ingresosMetadata" class="flex flex-col gap-3">
|
||||||
|
<!-- Header compacto -->
|
||||||
|
<div class="flex items-center justify-between pb-2 border-b border-[var(--brand-border)]">
|
||||||
|
<h3 class="text-sm font-bold text-[var(--brand-text)]">{{ ingresosMetadata.table }}</h3>
|
||||||
|
<span class="text-xs font-semibold text-[var(--brand-primary)]">
|
||||||
|
{{ formatNumber(recordCount) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info grid compacto -->
|
||||||
|
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<dt class="text-[var(--brand-text-muted)] uppercase text-[10px]">Tamaño</dt>
|
||||||
|
<dd class="font-medium text-[var(--brand-text)]">{{ formatSize(ingresosMetadata.approxSizeBytes) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-[var(--brand-text-muted)] uppercase text-[10px]">Desde</dt>
|
||||||
|
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(ingresosMetadata.createdAtRange?.from) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<dt class="text-[var(--brand-text-muted)] uppercase text-[10px]">Columnas ({{ ingresosMetadata.columns?.length || 0 }})</dt>
|
||||||
|
<dd class="font-medium text-[var(--brand-text)] truncate text-[10px]">
|
||||||
|
{{ (ingresosMetadata.columns || []).slice(0, 5).join(', ') }}{{ (ingresosMetadata.columns?.length || 0) > 5 ? '...' : '' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Última actualización -->
|
||||||
|
<div class="text-[10px] text-[var(--brand-text-muted)] pt-2 border-t border-[var(--brand-border)]">
|
||||||
|
{{ tableStore ? tableStore.formattedLastUpdated : 'Sin datos' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones de acción compactos -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton
|
||||||
|
:loading="isLoadingLatest"
|
||||||
|
:disabled="isLoadingAll"
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
@click="loadLatestData"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-clock" class="w-3 h-3" />
|
||||||
|
</template>
|
||||||
|
Últimos
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
:loading="isLoadingAll"
|
||||||
|
:disabled="isLoadingLatest"
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
variant="outline"
|
||||||
|
@click="loadAllData"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-database" class="w-3 h-3" />
|
||||||
|
</template>
|
||||||
|
Todos
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar compacto -->
|
||||||
|
<UProgress
|
||||||
|
v-if="isLoadingLatest || isLoadingAll"
|
||||||
|
:model-value="loadingProgress"
|
||||||
|
:max="100"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ingresosMetadata: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { $getTableStore } = useNuxtApp()
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const isLoadingLatest = ref(false)
|
||||||
|
const isLoadingAll = ref(false)
|
||||||
|
const loadingProgress = ref(0)
|
||||||
|
|
||||||
|
// Get the table store
|
||||||
|
const tableStore = computed(() => {
|
||||||
|
if (typeof $getTableStore === 'function') {
|
||||||
|
return $getTableStore(props.ingresosMetadata.name)
|
||||||
|
}
|
||||||
|
return useTableDataStore(props.ingresosMetadata.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate record count
|
||||||
|
const recordCount = computed(() => {
|
||||||
|
return tableStore.value?.recordCount || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadLatestData() {
|
||||||
|
isLoadingLatest.value = true
|
||||||
|
loadingProgress.value = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = tableStore.value
|
||||||
|
if (!store) return
|
||||||
|
|
||||||
|
await store.loadLatestDataInBatches((progress) => {
|
||||||
|
loadingProgress.value = progress
|
||||||
|
})
|
||||||
|
|
||||||
|
loadingProgress.value = 100
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading latest data:', error)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
isLoadingLatest.value = false
|
||||||
|
loadingProgress.value = 0
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllData() {
|
||||||
|
isLoadingAll.value = true
|
||||||
|
loadingProgress.value = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = tableStore.value
|
||||||
|
if (!store) return
|
||||||
|
|
||||||
|
await store.loadAllDataInBatches((progress) => {
|
||||||
|
loadingProgress.value = progress
|
||||||
|
})
|
||||||
|
|
||||||
|
loadingProgress.value = 100
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading all data:', error)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
isLoadingAll.value = false
|
||||||
|
loadingProgress.value = 0
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number | null | undefined): string {
|
||||||
|
if (!bytes) return 'N/A'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB']
|
||||||
|
let size = bytes / 1024
|
||||||
|
let unitIndex = 0
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return date.toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return new Intl.NumberFormat('es-ES').format(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,23 +2,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<UCard class="brand-card border border-transparent">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3">
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-bold brand-section-title">Acumulación de Café</h3>
|
<div>
|
||||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
<h3 class="text-lg font-bold brand-section-title">Acumulación de Café</h3>
|
||||||
Evolución acumulada en el tiempo
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||||
</p>
|
Evolución acumulada en el tiempo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="tipo in tipos"
|
||||||
|
:key="tipo.value"
|
||||||
|
:model-value="tiposSeleccionados.includes(tipo.value)"
|
||||||
|
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
||||||
|
:label="getTipoLabel(tipo)"
|
||||||
|
:disabled="isTipoDisabled(tipo.value)"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<UCheckbox
|
<!-- Selector de Granularidad Temporal -->
|
||||||
v-for="tipo in tipos"
|
<div class="flex items-center gap-2">
|
||||||
:key="tipo.value"
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Granularidad:</span>
|
||||||
:model-value="tiposSeleccionados.includes(tipo.value)"
|
<div class="flex items-center gap-1">
|
||||||
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
<button
|
||||||
:label="getTipoLabel(tipo)"
|
v-for="option in granularityOptions"
|
||||||
:disabled="isTipoDisabled(tipo.value)"
|
:key="option.value"
|
||||||
size="xs"
|
@click="selectedGranularity = option.value"
|
||||||
/>
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all',
|
||||||
|
selectedGranularity === option.value
|
||||||
|
? 'bg-[var(--brand-primary)] text-white shadow-sm'
|
||||||
|
: 'bg-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,10 +102,12 @@
|
|||||||
</circle>
|
</circle>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- X-axis labels (usando el primer tipo activo como referencia) -->
|
<!-- X-axis labels (distribuidos uniformemente) -->
|
||||||
<g v-if="tiposActivos.length > 0" v-for="(point, i) in getPointsForTipo(tiposActivos[0].value)" :key="`label-${i}`">
|
<g v-if="tiposActivos.length > 0">
|
||||||
<text
|
<text
|
||||||
v-if="i % Math.ceil(getPointsForTipo(tiposActivos[0].value).length / 6) === 0 || i === getPointsForTipo(tiposActivos[0].value).length - 1"
|
v-for="(point, i) in getPointsForTipo(tiposActivos[0].value)"
|
||||||
|
:key="`label-${i}`"
|
||||||
|
v-show="shouldShowLabel(i, getPointsForTipo(tiposActivos[0].value).length)"
|
||||||
:x="point.x"
|
:x="point.x"
|
||||||
:y="height - padding + 20"
|
:y="height - padding + 20"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
@@ -133,6 +158,18 @@ const tiposSeleccionados = ref(['uva'])
|
|||||||
|
|
||||||
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
||||||
|
|
||||||
|
// Selector de granularidad temporal
|
||||||
|
type GranularityMode = 'auto' | 'year' | 'month' | 'day' | 'hour'
|
||||||
|
const selectedGranularity = ref<GranularityMode>('auto')
|
||||||
|
|
||||||
|
const granularityOptions = [
|
||||||
|
{ value: 'auto' as GranularityMode, label: 'Auto', icon: 'i-lucide-wand-2' },
|
||||||
|
{ value: 'year' as GranularityMode, label: 'Años', icon: 'i-lucide-calendar' },
|
||||||
|
{ value: 'month' as GranularityMode, label: 'Meses', icon: 'i-lucide-calendar-days' },
|
||||||
|
{ value: 'day' as GranularityMode, label: 'Días', icon: 'i-lucide-calendar-clock' },
|
||||||
|
{ value: 'hour' as GranularityMode, label: 'Horas', icon: 'i-lucide-clock' }
|
||||||
|
]
|
||||||
|
|
||||||
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
||||||
const modoSeco = computed(() => {
|
const modoSeco = computed(() => {
|
||||||
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
||||||
@@ -200,29 +237,120 @@ interface DataPoint {
|
|||||||
dateLabel: string
|
dateLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Función para detectar granularidad temporal
|
||||||
|
function getTimeGranularity(startDate: Date, endDate: Date): 'year' | 'month' | 'day' | 'hour' {
|
||||||
|
// Si no es auto, usar la selección manual
|
||||||
|
if (selectedGranularity.value !== 'auto') {
|
||||||
|
return selectedGranularity.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto: detectar según el rango
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime()
|
||||||
|
const diffDays = diffMs / (1000 * 60 * 60 * 24)
|
||||||
|
const diffMonths = diffDays / 30
|
||||||
|
const diffYears = diffDays / 365
|
||||||
|
|
||||||
|
if (diffYears > 2) return 'year'
|
||||||
|
if (diffMonths > 2) return 'month'
|
||||||
|
if (diffDays > 2) return 'day'
|
||||||
|
return 'hour'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para generar timestamps completos según granularidad
|
||||||
|
function generateTimeRange(startDate: Date, endDate: Date, granularity: 'year' | 'month' | 'day' | 'hour'): Date[] {
|
||||||
|
const dates: Date[] = []
|
||||||
|
const current = new Date(startDate)
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
dates.push(new Date(current))
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
current.setFullYear(current.getFullYear() + 1)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
current.setMonth(current.getMonth() + 1)
|
||||||
|
break
|
||||||
|
case 'day':
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
break
|
||||||
|
case 'hour':
|
||||||
|
current.setHours(current.getHours() + 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para formatear fecha según granularidad
|
||||||
|
function formatDateByGranularity(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return date.getFullYear().toString()
|
||||||
|
case 'month':
|
||||||
|
return date.toLocaleDateString('es-HN', { year: 'numeric', month: 'short' })
|
||||||
|
case 'day':
|
||||||
|
return date.toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
||||||
|
case 'hour':
|
||||||
|
return date.toLocaleTimeString('es-HN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para agrupar fecha según granularidad (devuelve formato ISO parseable)
|
||||||
|
function getDateKey(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return `${year}`
|
||||||
|
case 'month':
|
||||||
|
return `${year}-${month}`
|
||||||
|
case 'day':
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
case 'hour':
|
||||||
|
return `${year}-${month}-${day}T${hour}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Datos por tipo
|
// Datos por tipo
|
||||||
const dataByTipo = computed(() => {
|
const dataByTipo = computed(() => {
|
||||||
const result: Record<string, DataPoint[]> = {}
|
const result: Record<string, DataPoint[]> = {}
|
||||||
|
|
||||||
|
// Encontrar rango temporal solo de los tipos seleccionados
|
||||||
|
const allDates = props.ingresos
|
||||||
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
|
.filter(i => i.created_at)
|
||||||
|
.map(i => new Date(i.created_at!))
|
||||||
|
|
||||||
|
if (allDates.length === 0) {
|
||||||
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
|
result[tipo] = []
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
const timeRange = generateTimeRange(minDate, maxDate, granularity)
|
||||||
|
|
||||||
tiposSeleccionados.value.forEach(tipo => {
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
const ingresosFiltrados = props.ingresos
|
const ingresosFiltrados = props.ingresos
|
||||||
.filter(i => i.tipo === tipo)
|
.filter(i => i.tipo === tipo)
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
.sort((a, b) => new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime())
|
|
||||||
|
|
||||||
if (ingresosFiltrados.length === 0) {
|
const porPeriodo = new Map<string, number>()
|
||||||
result[tipo] = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const porDia = new Map<string, number>()
|
|
||||||
|
|
||||||
ingresosFiltrados.forEach(ingreso => {
|
ingresosFiltrados.forEach(ingreso => {
|
||||||
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
|
const fecha = new Date(ingreso.created_at!)
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
let valor = 0
|
let valor = 0
|
||||||
|
|
||||||
if (tipo === 'uva') {
|
if (tipo === 'uva') {
|
||||||
// Si estamos en modo seco, convertir uva a qq (dividir entre 500)
|
|
||||||
valor = modoSeco.value ? ingreso.peso_neto / 500 : ingreso.peso_neto
|
valor = modoSeco.value ? ingreso.peso_neto / 500 : ingreso.peso_neto
|
||||||
} else if (tipo === 'verde') {
|
} else if (tipo === 'verde') {
|
||||||
valor = ingreso.peso_neto
|
valor = ingreso.peso_neto
|
||||||
@@ -230,22 +358,25 @@ const dataByTipo = computed(() => {
|
|||||||
valor = ingreso.peso_seco
|
valor = ingreso.peso_seco
|
||||||
}
|
}
|
||||||
|
|
||||||
porDia.set(fecha, (porDia.get(fecha) || 0) + valor)
|
porPeriodo.set(key, (porPeriodo.get(key) || 0) + valor)
|
||||||
})
|
})
|
||||||
|
|
||||||
let acumulado = 0
|
let acumulado = 0
|
||||||
const puntos: DataPoint[] = []
|
const puntos: DataPoint[] = []
|
||||||
|
|
||||||
Array.from(porDia.entries()).forEach(([fecha, valor]) => {
|
timeRange.forEach(fecha => {
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
|
const valor = porPeriodo.get(key) || 0
|
||||||
acumulado += valor
|
acumulado += valor
|
||||||
|
|
||||||
puntos.push({
|
puntos.push({
|
||||||
date: new Date(fecha),
|
date: fecha,
|
||||||
value: valor,
|
value: valor,
|
||||||
acumulado,
|
acumulado,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
label: fecha,
|
label: key,
|
||||||
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
dateLabel: formatDateByGranularity(fecha, granularity)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,15 +397,27 @@ const maxValue = computed(() => {
|
|||||||
return max * 1.1 || 100
|
return max * 1.1 || 100
|
||||||
})
|
})
|
||||||
|
|
||||||
// Obtener todas las fechas únicas
|
// Obtener todas las fechas únicas (ahora se genera automáticamente)
|
||||||
const allDates = computed(() => {
|
const allDates = computed(() => {
|
||||||
const fechas = new Set<string>()
|
const firstTipo = tiposSeleccionados.value[0]
|
||||||
Object.values(dataByTipo.value).forEach(puntos => {
|
if (!firstTipo || !dataByTipo.value[firstTipo]) return []
|
||||||
puntos.forEach(p => fechas.add(p.label))
|
return dataByTipo.value[firstTipo].map(p => p.label)
|
||||||
})
|
|
||||||
return Array.from(fechas).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Determinar si mostrar una etiqueta (máximo 10 etiquetas en el eje)
|
||||||
|
function shouldShowLabel(index: number, total: number): boolean {
|
||||||
|
if (total <= 10) return true
|
||||||
|
|
||||||
|
const maxLabels = 10
|
||||||
|
const step = Math.ceil(total / maxLabels)
|
||||||
|
|
||||||
|
// Siempre mostrar primera y última
|
||||||
|
if (index === 0 || index === total - 1) return true
|
||||||
|
|
||||||
|
// Mostrar cada N puntos
|
||||||
|
return index % step === 0
|
||||||
|
}
|
||||||
|
|
||||||
// Calcular puntos con coordenadas para cada tipo
|
// Calcular puntos con coordenadas para cada tipo
|
||||||
function getPointsForTipo(tipo: string) {
|
function getPointsForTipo(tipo: string) {
|
||||||
const data = dataByTipo.value[tipo]
|
const data = dataByTipo.value[tipo]
|
||||||
@@ -308,6 +451,8 @@ function getAreaPathForTipo(tipo: string) {
|
|||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const firstPoint = points[0]
|
const firstPoint = points[0]
|
||||||
|
|
||||||
|
if (!lastPoint || !firstPoint) return ''
|
||||||
|
|
||||||
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
|
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<UCard class="brand-card border border-transparent">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3">
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-bold brand-section-title">Dinámica: Pagado vs Depósito</h3>
|
<div>
|
||||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
<h3 class="text-lg font-bold brand-section-title">Dinámica: Pagado vs Depósito</h3>
|
||||||
Evolución de café pagado y en depósito
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||||
</p>
|
Evolución de café pagado y en depósito
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="tipo in tipos"
|
||||||
|
:key="tipo.value"
|
||||||
|
:model-value="tiposSeleccionados.includes(tipo.value)"
|
||||||
|
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
||||||
|
:label="getTipoLabel(tipo)"
|
||||||
|
:disabled="isTipoDisabled(tipo.value)"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<UCheckbox
|
<!-- Selector de Granularidad Temporal -->
|
||||||
v-for="tipo in tipos"
|
<div class="flex items-center gap-2">
|
||||||
:key="tipo.value"
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Granularidad:</span>
|
||||||
:model-value="tiposSeleccionados.includes(tipo.value)"
|
<div class="flex items-center gap-1">
|
||||||
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
<button
|
||||||
:label="getTipoLabel(tipo)"
|
v-for="option in granularityOptions"
|
||||||
:disabled="isTipoDisabled(tipo.value)"
|
:key="option.value"
|
||||||
size="xs"
|
@click="selectedGranularity = option.value"
|
||||||
/>
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all',
|
||||||
|
selectedGranularity === option.value
|
||||||
|
? 'bg-[var(--brand-primary)] text-white shadow-sm'
|
||||||
|
: 'bg-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -112,10 +135,12 @@
|
|||||||
</circle>
|
</circle>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- X-axis labels -->
|
<!-- X-axis labels (distribuidos uniformemente) -->
|
||||||
<g v-if="tiposActivos.length > 0" v-for="(point, i) in getPointsPagadoForTipo(tiposActivos[0].value)" :key="`label-${i}`">
|
<g v-if="tiposActivos.length > 0">
|
||||||
<text
|
<text
|
||||||
v-if="i % Math.ceil(getPointsPagadoForTipo(tiposActivos[0].value).length / 6) === 0 || i === getPointsPagadoForTipo(tiposActivos[0].value).length - 1"
|
v-for="(point, i) in getPointsPagadoForTipo(tiposActivos[0].value)"
|
||||||
|
:key="`label-${i}`"
|
||||||
|
v-show="shouldShowLabel(i, getPointsPagadoForTipo(tiposActivos[0].value).length)"
|
||||||
:x="point.x"
|
:x="point.x"
|
||||||
:y="height - padding + 20"
|
:y="height - padding + 20"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
@@ -174,6 +199,18 @@ const tiposSeleccionados = ref(['uva'])
|
|||||||
|
|
||||||
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
||||||
|
|
||||||
|
// Selector de granularidad temporal
|
||||||
|
type GranularityMode = 'auto' | 'year' | 'month' | 'day' | 'hour'
|
||||||
|
const selectedGranularity = ref<GranularityMode>('auto')
|
||||||
|
|
||||||
|
const granularityOptions = [
|
||||||
|
{ value: 'auto' as GranularityMode, label: 'Auto', icon: 'i-lucide-wand-2' },
|
||||||
|
{ value: 'year' as GranularityMode, label: 'Años', icon: 'i-lucide-calendar' },
|
||||||
|
{ value: 'month' as GranularityMode, label: 'Meses', icon: 'i-lucide-calendar-days' },
|
||||||
|
{ value: 'day' as GranularityMode, label: 'Días', icon: 'i-lucide-calendar-clock' },
|
||||||
|
{ value: 'hour' as GranularityMode, label: 'Horas', icon: 'i-lucide-clock' }
|
||||||
|
]
|
||||||
|
|
||||||
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
||||||
const modoSeco = computed(() => {
|
const modoSeco = computed(() => {
|
||||||
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
||||||
@@ -240,30 +277,135 @@ interface DataPoint {
|
|||||||
dateLabel: string
|
dateLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Función para detectar granularidad temporal
|
||||||
|
function getTimeGranularity(startDate: Date, endDate: Date): 'year' | 'month' | 'day' | 'hour' {
|
||||||
|
// Si no es auto, usar la selección manual
|
||||||
|
if (selectedGranularity.value !== 'auto') {
|
||||||
|
return selectedGranularity.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto: detectar según el rango
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime()
|
||||||
|
const diffDays = diffMs / (1000 * 60 * 60 * 24)
|
||||||
|
const diffMonths = diffDays / 30
|
||||||
|
const diffYears = diffDays / 365
|
||||||
|
|
||||||
|
if (diffYears > 2) return 'year'
|
||||||
|
if (diffMonths > 2) return 'month'
|
||||||
|
if (diffDays > 2) return 'day'
|
||||||
|
return 'hour'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para generar timestamps completos según granularidad
|
||||||
|
function generateTimeRange(startDate: Date, endDate: Date, granularity: 'year' | 'month' | 'day' | 'hour'): Date[] {
|
||||||
|
const dates: Date[] = []
|
||||||
|
const current = new Date(startDate)
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
dates.push(new Date(current))
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
current.setFullYear(current.getFullYear() + 1)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
current.setMonth(current.getMonth() + 1)
|
||||||
|
break
|
||||||
|
case 'day':
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
break
|
||||||
|
case 'hour':
|
||||||
|
current.setHours(current.getHours() + 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para formatear fecha según granularidad
|
||||||
|
function formatDateByGranularity(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return date.getFullYear().toString()
|
||||||
|
case 'month':
|
||||||
|
return date.toLocaleDateString('es-HN', { year: 'numeric', month: 'short' })
|
||||||
|
case 'day':
|
||||||
|
return date.toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
||||||
|
case 'hour':
|
||||||
|
return date.toLocaleTimeString('es-HN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para agrupar fecha según granularidad (devuelve formato ISO parseable)
|
||||||
|
function getDateKey(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return `${year}`
|
||||||
|
case 'month':
|
||||||
|
return `${year}-${month}`
|
||||||
|
case 'day':
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
case 'hour':
|
||||||
|
return `${year}-${month}-${day}T${hour}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar si mostrar una etiqueta (máximo 10 etiquetas en el eje)
|
||||||
|
function shouldShowLabel(index: number, total: number): boolean {
|
||||||
|
if (total <= 10) return true
|
||||||
|
|
||||||
|
const maxLabels = 10
|
||||||
|
const step = Math.ceil(total / maxLabels)
|
||||||
|
|
||||||
|
// Siempre mostrar primera y última
|
||||||
|
if (index === 0 || index === total - 1) return true
|
||||||
|
|
||||||
|
// Mostrar cada N puntos
|
||||||
|
return index % step === 0
|
||||||
|
}
|
||||||
|
|
||||||
// Datos pagado por tipo
|
// Datos pagado por tipo
|
||||||
const dataPagadoByTipo = computed(() => {
|
const dataPagadoByTipo = computed(() => {
|
||||||
const result: Record<string, DataPoint[]> = {}
|
const result: Record<string, DataPoint[]> = {}
|
||||||
|
|
||||||
|
// Encontrar rango temporal solo de los tipos seleccionados
|
||||||
|
const allDates = props.ingresos
|
||||||
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
|
.filter(i => i.created_at)
|
||||||
|
.map(i => new Date(i.created_at!))
|
||||||
|
|
||||||
|
if (allDates.length === 0) {
|
||||||
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
|
result[tipo] = []
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
const timeRange = generateTimeRange(minDate, maxDate, granularity)
|
||||||
|
|
||||||
tiposSeleccionados.value.forEach(tipo => {
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
const ingresosFiltrados = props.ingresos
|
const ingresosFiltrados = props.ingresos
|
||||||
.filter(i => i.tipo === tipo)
|
.filter(i => i.tipo === tipo)
|
||||||
.filter(i => i.estado === 'pagado')
|
.filter(i => i.estado === 'pagado')
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
.sort((a, b) => new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime())
|
|
||||||
|
|
||||||
if (ingresosFiltrados.length === 0) {
|
const porPeriodo = new Map<string, number>()
|
||||||
result[tipo] = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const porDia = new Map<string, number>()
|
|
||||||
|
|
||||||
ingresosFiltrados.forEach(ingreso => {
|
ingresosFiltrados.forEach(ingreso => {
|
||||||
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
|
const fecha = new Date(ingreso.created_at!)
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
let valor = 0
|
let valor = 0
|
||||||
|
|
||||||
if (tipo === 'uva') {
|
if (tipo === 'uva') {
|
||||||
// Si estamos en modo seco, convertir uva a qq (dividir entre 500)
|
|
||||||
valor = modoSeco.value ? ingreso.peso_neto / 500 : ingreso.peso_neto
|
valor = modoSeco.value ? ingreso.peso_neto / 500 : ingreso.peso_neto
|
||||||
} else if (tipo === 'verde') {
|
} else if (tipo === 'verde') {
|
||||||
valor = ingreso.peso_neto
|
valor = ingreso.peso_neto
|
||||||
@@ -271,21 +413,24 @@ const dataPagadoByTipo = computed(() => {
|
|||||||
valor = ingreso.peso_seco
|
valor = ingreso.peso_seco
|
||||||
}
|
}
|
||||||
|
|
||||||
porDia.set(fecha, (porDia.get(fecha) || 0) + valor)
|
porPeriodo.set(key, (porPeriodo.get(key) || 0) + valor)
|
||||||
})
|
})
|
||||||
|
|
||||||
let acumulado = 0
|
let acumulado = 0
|
||||||
const puntos: DataPoint[] = []
|
const puntos: DataPoint[] = []
|
||||||
|
|
||||||
Array.from(porDia.entries()).forEach(([fecha, valor]) => {
|
timeRange.forEach(fecha => {
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
|
const valor = porPeriodo.get(key) || 0
|
||||||
acumulado += valor
|
acumulado += valor
|
||||||
|
|
||||||
puntos.push({
|
puntos.push({
|
||||||
date: new Date(fecha),
|
date: fecha,
|
||||||
value: acumulado,
|
value: acumulado,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
label: fecha,
|
label: key,
|
||||||
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
dateLabel: formatDateByGranularity(fecha, granularity)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -299,27 +444,40 @@ const dataPagadoByTipo = computed(() => {
|
|||||||
const dataDepositoByTipo = computed(() => {
|
const dataDepositoByTipo = computed(() => {
|
||||||
const result: Record<string, DataPoint[]> = {}
|
const result: Record<string, DataPoint[]> = {}
|
||||||
|
|
||||||
|
// Encontrar rango temporal solo de los tipos seleccionados
|
||||||
|
const allDates = props.ingresos
|
||||||
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
|
.filter(i => i.created_at)
|
||||||
|
.map(i => new Date(i.created_at!))
|
||||||
|
|
||||||
|
if (allDates.length === 0) {
|
||||||
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
|
result[tipo] = []
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
const timeRange = generateTimeRange(minDate, maxDate, granularity)
|
||||||
|
|
||||||
tiposSeleccionados.value.forEach(tipo => {
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
const todosFiltrados = props.ingresos
|
const todosFiltrados = props.ingresos
|
||||||
.filter(i => i.tipo === tipo)
|
.filter(i => i.tipo === tipo)
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
.sort((a, b) => new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime())
|
|
||||||
|
|
||||||
const pagadosFiltrados = props.ingresos
|
const pagadosFiltrados = props.ingresos
|
||||||
.filter(i => i.tipo === tipo)
|
.filter(i => i.tipo === tipo)
|
||||||
.filter(i => i.estado === 'pagado')
|
.filter(i => i.estado === 'pagado')
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
|
|
||||||
if (todosFiltrados.length === 0) {
|
const totalPorPeriodo = new Map<string, number>()
|
||||||
result[tipo] = []
|
const pagadoPorPeriodo = new Map<string, number>()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPorDia = new Map<string, number>()
|
|
||||||
const pagadoPorDia = new Map<string, number>()
|
|
||||||
|
|
||||||
todosFiltrados.forEach(ingreso => {
|
todosFiltrados.forEach(ingreso => {
|
||||||
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
|
const fecha = new Date(ingreso.created_at!)
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
let valor = 0
|
let valor = 0
|
||||||
|
|
||||||
if (tipo === 'uva') {
|
if (tipo === 'uva') {
|
||||||
@@ -330,11 +488,12 @@ const dataDepositoByTipo = computed(() => {
|
|||||||
valor = ingreso.peso_seco
|
valor = ingreso.peso_seco
|
||||||
}
|
}
|
||||||
|
|
||||||
totalPorDia.set(fecha, (totalPorDia.get(fecha) || 0) + valor)
|
totalPorPeriodo.set(key, (totalPorPeriodo.get(key) || 0) + valor)
|
||||||
})
|
})
|
||||||
|
|
||||||
pagadosFiltrados.forEach(ingreso => {
|
pagadosFiltrados.forEach(ingreso => {
|
||||||
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
|
const fecha = new Date(ingreso.created_at!)
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
let valor = 0
|
let valor = 0
|
||||||
|
|
||||||
if (tipo === 'uva') {
|
if (tipo === 'uva') {
|
||||||
@@ -345,28 +504,26 @@ const dataDepositoByTipo = computed(() => {
|
|||||||
valor = ingreso.peso_seco
|
valor = ingreso.peso_seco
|
||||||
}
|
}
|
||||||
|
|
||||||
pagadoPorDia.set(fecha, (pagadoPorDia.get(fecha) || 0) + valor)
|
pagadoPorPeriodo.set(key, (pagadoPorPeriodo.get(key) || 0) + valor)
|
||||||
})
|
})
|
||||||
|
|
||||||
let acumuladoTotal = 0
|
let acumuladoTotal = 0
|
||||||
let acumuladoPagado = 0
|
let acumuladoPagado = 0
|
||||||
const puntos: DataPoint[] = []
|
const puntos: DataPoint[] = []
|
||||||
|
|
||||||
const fechasUnicas = new Set([...totalPorDia.keys(), ...pagadoPorDia.keys()])
|
timeRange.forEach(fecha => {
|
||||||
const fechasOrdenadas = Array.from(fechasUnicas).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
const key = getDateKey(fecha, granularity)
|
||||||
|
acumuladoTotal += totalPorPeriodo.get(key) || 0
|
||||||
fechasOrdenadas.forEach(fecha => {
|
acumuladoPagado += pagadoPorPeriodo.get(key) || 0
|
||||||
acumuladoTotal += totalPorDia.get(fecha) || 0
|
|
||||||
acumuladoPagado += pagadoPorDia.get(fecha) || 0
|
|
||||||
const deposito = acumuladoTotal - acumuladoPagado
|
const deposito = acumuladoTotal - acumuladoPagado
|
||||||
|
|
||||||
puntos.push({
|
puntos.push({
|
||||||
date: new Date(fecha),
|
date: fecha,
|
||||||
value: deposito,
|
value: deposito,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
label: fecha,
|
label: key,
|
||||||
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
dateLabel: formatDateByGranularity(fecha, granularity)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -456,6 +613,8 @@ function getAreaPagadoPathForTipo(tipo: string) {
|
|||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const firstPoint = points[0]
|
const firstPoint = points[0]
|
||||||
|
|
||||||
|
if (!lastPoint || !firstPoint) return ''
|
||||||
|
|
||||||
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
|
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +629,8 @@ function getAreaDepositoPathForTipo(tipo: string) {
|
|||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const firstPoint = points[0]
|
const firstPoint = points[0]
|
||||||
|
|
||||||
|
if (!lastPoint || !firstPoint) return ''
|
||||||
|
|
||||||
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
|
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<UCard class="brand-card border border-transparent">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3">
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-bold brand-section-title">Serie Temporal: Ingresos Diarios</h3>
|
<div>
|
||||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
<h3 class="text-lg font-bold brand-section-title">Serie Temporal: Ingresos Diarios</h3>
|
||||||
Cantidad ingresada por día
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||||
</p>
|
Cantidad ingresada por día
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="tipo in tipos"
|
||||||
|
:key="tipo.value"
|
||||||
|
:model-value="tiposSeleccionados.includes(tipo.value)"
|
||||||
|
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
||||||
|
:label="getTipoLabel(tipo)"
|
||||||
|
:disabled="isTipoDisabled(tipo.value)"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<UCheckbox
|
<!-- Selector de Granularidad Temporal -->
|
||||||
v-for="tipo in tipos"
|
<div class="flex items-center gap-2">
|
||||||
:key="tipo.value"
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Granularidad:</span>
|
||||||
:model-value="tiposSeleccionados.includes(tipo.value)"
|
<div class="flex items-center gap-1">
|
||||||
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
<button
|
||||||
:label="getTipoLabel(tipo)"
|
v-for="option in granularityOptions"
|
||||||
:disabled="isTipoDisabled(tipo.value)"
|
:key="option.value"
|
||||||
size="xs"
|
@click="selectedGranularity = option.value"
|
||||||
/>
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all',
|
||||||
|
selectedGranularity === option.value
|
||||||
|
? 'bg-[var(--brand-primary)] text-white shadow-sm'
|
||||||
|
: 'bg-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -63,16 +86,18 @@
|
|||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- X-axis labels -->
|
<!-- X-axis labels (distribuidos uniformemente) -->
|
||||||
<g v-for="(fecha, i) in fechasUnicas" :key="`label-${i}`">
|
<g v-if="tiposActivos.length > 0">
|
||||||
<text
|
<text
|
||||||
v-if="i % Math.ceil(fechasUnicas.length / 8) === 0 || i === fechasUnicas.length - 1"
|
v-for="(point, i) in getPointsForTipo(tiposActivos[0].value)"
|
||||||
:x="padding + (i / (fechasUnicas.length - 1 || 1)) * chartWidth + barWidth / 2"
|
:key="`label-${i}`"
|
||||||
|
v-show="shouldShowLabel(i, fechasUnicas.length)"
|
||||||
|
:x="point.x + barWidth / 2"
|
||||||
:y="height - padding + 20"
|
:y="height - padding + 20"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="text-xs fill-[var(--brand-text-muted)]"
|
class="text-xs fill-[var(--brand-text-muted)]"
|
||||||
>
|
>
|
||||||
{{ new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' }) }}
|
{{ point.dateLabel }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -117,6 +142,18 @@ const tiposSeleccionados = ref(['uva'])
|
|||||||
|
|
||||||
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
||||||
|
|
||||||
|
// Selector de granularidad temporal
|
||||||
|
type GranularityMode = 'auto' | 'year' | 'month' | 'day' | 'hour'
|
||||||
|
const selectedGranularity = ref<GranularityMode>('auto')
|
||||||
|
|
||||||
|
const granularityOptions = [
|
||||||
|
{ value: 'auto' as GranularityMode, label: 'Auto', icon: 'i-lucide-wand-2' },
|
||||||
|
{ value: 'year' as GranularityMode, label: 'Años', icon: 'i-lucide-calendar' },
|
||||||
|
{ value: 'month' as GranularityMode, label: 'Meses', icon: 'i-lucide-calendar-days' },
|
||||||
|
{ value: 'day' as GranularityMode, label: 'Días', icon: 'i-lucide-calendar-clock' },
|
||||||
|
{ value: 'hour' as GranularityMode, label: 'Horas', icon: 'i-lucide-clock' }
|
||||||
|
]
|
||||||
|
|
||||||
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
||||||
const modoSeco = computed(() => {
|
const modoSeco = computed(() => {
|
||||||
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
||||||
@@ -180,19 +217,117 @@ interface DataPoint {
|
|||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
label: string
|
label: string
|
||||||
|
dateLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener todas las fechas únicas
|
// Función para detectar granularidad temporal
|
||||||
|
function getTimeGranularity(startDate: Date, endDate: Date): 'year' | 'month' | 'day' | 'hour' {
|
||||||
|
// Si no es auto, usar la selección manual
|
||||||
|
if (selectedGranularity.value !== 'auto') {
|
||||||
|
return selectedGranularity.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto: detectar según el rango
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime()
|
||||||
|
const diffDays = diffMs / (1000 * 60 * 60 * 24)
|
||||||
|
const diffMonths = diffDays / 30
|
||||||
|
const diffYears = diffDays / 365
|
||||||
|
|
||||||
|
if (diffYears > 2) return 'year'
|
||||||
|
if (diffMonths > 2) return 'month'
|
||||||
|
if (diffDays > 2) return 'day'
|
||||||
|
return 'hour'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para generar timestamps completos según granularidad
|
||||||
|
function generateTimeRange(startDate: Date, endDate: Date, granularity: 'year' | 'month' | 'day' | 'hour'): Date[] {
|
||||||
|
const dates: Date[] = []
|
||||||
|
const current = new Date(startDate)
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
dates.push(new Date(current))
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
current.setFullYear(current.getFullYear() + 1)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
current.setMonth(current.getMonth() + 1)
|
||||||
|
break
|
||||||
|
case 'day':
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
break
|
||||||
|
case 'hour':
|
||||||
|
current.setHours(current.getHours() + 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para formatear fecha según granularidad
|
||||||
|
function formatDateByGranularity(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return date.getFullYear().toString()
|
||||||
|
case 'month':
|
||||||
|
return date.toLocaleDateString('es-HN', { year: 'numeric', month: 'short' })
|
||||||
|
case 'day':
|
||||||
|
return date.toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
||||||
|
case 'hour':
|
||||||
|
return date.toLocaleTimeString('es-HN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para agrupar fecha según granularidad (devuelve formato ISO parseable)
|
||||||
|
function getDateKey(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return `${year}`
|
||||||
|
case 'month':
|
||||||
|
return `${year}-${month}`
|
||||||
|
case 'day':
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
case 'hour':
|
||||||
|
return `${year}-${month}-${day}T${hour}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar si mostrar una etiqueta (máximo 10 etiquetas en el eje)
|
||||||
|
function shouldShowLabel(index: number, total: number): boolean {
|
||||||
|
if (total <= 10) return true
|
||||||
|
|
||||||
|
const maxLabels = 10
|
||||||
|
const step = Math.ceil(total / maxLabels)
|
||||||
|
|
||||||
|
// Siempre mostrar primera y última
|
||||||
|
if (index === 0 || index === total - 1) return true
|
||||||
|
|
||||||
|
// Mostrar cada N puntos
|
||||||
|
return index % step === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todas las fechas únicas (ahora generadas completas)
|
||||||
const fechasUnicas = computed(() => {
|
const fechasUnicas = computed(() => {
|
||||||
const fechas = new Set<string>()
|
const allDates = props.ingresos
|
||||||
props.ingresos
|
|
||||||
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
.forEach(i => {
|
.map(i => new Date(i.created_at!))
|
||||||
const fecha = new Date(i.created_at!).toLocaleDateString('es-HN')
|
|
||||||
fechas.add(fecha)
|
if (allDates.length === 0) return []
|
||||||
})
|
|
||||||
return Array.from(fechas).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
const timeRange = generateTimeRange(minDate, maxDate, granularity)
|
||||||
|
|
||||||
|
return timeRange.map(fecha => getDateKey(fecha, granularity))
|
||||||
})
|
})
|
||||||
|
|
||||||
const barWidth = computed(() => {
|
const barWidth = computed(() => {
|
||||||
@@ -203,6 +338,22 @@ const barWidth = computed(() => {
|
|||||||
const dataByTipo = computed(() => {
|
const dataByTipo = computed(() => {
|
||||||
const result: Record<string, Map<string, number>> = {}
|
const result: Record<string, Map<string, number>> = {}
|
||||||
|
|
||||||
|
const allDates = props.ingresos
|
||||||
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
|
.filter(i => i.created_at)
|
||||||
|
.map(i => new Date(i.created_at!))
|
||||||
|
|
||||||
|
if (allDates.length === 0) {
|
||||||
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
|
result[tipo] = new Map<string, number>()
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
|
||||||
tiposSeleccionados.value.forEach(tipo => {
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
result[tipo] = new Map<string, number>()
|
result[tipo] = new Map<string, number>()
|
||||||
|
|
||||||
@@ -210,7 +361,8 @@ const dataByTipo = computed(() => {
|
|||||||
.filter(i => i.tipo === tipo)
|
.filter(i => i.tipo === tipo)
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
.forEach(ingreso => {
|
.forEach(ingreso => {
|
||||||
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
|
const fecha = new Date(ingreso.created_at!)
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
let valor = 0
|
let valor = 0
|
||||||
|
|
||||||
if (tipo === 'uva') {
|
if (tipo === 'uva') {
|
||||||
@@ -221,7 +373,7 @@ const dataByTipo = computed(() => {
|
|||||||
valor = ingreso.peso_seco
|
valor = ingreso.peso_seco
|
||||||
}
|
}
|
||||||
|
|
||||||
result[tipo].set(fecha, (result[tipo].get(fecha) || 0) + valor)
|
result[tipo].set(key, (result[tipo].get(key) || 0) + valor)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -242,17 +394,44 @@ function getPointsForTipo(tipo: string): DataPoint[] {
|
|||||||
const data = dataByTipo.value[tipo]
|
const data = dataByTipo.value[tipo]
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
|
|
||||||
|
const allDates = props.ingresos
|
||||||
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
|
.filter(i => i.created_at)
|
||||||
|
.map(i => new Date(i.created_at!))
|
||||||
|
|
||||||
|
if (allDates.length === 0) return []
|
||||||
|
|
||||||
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
|
||||||
return fechasUnicas.value.map((fecha, i) => {
|
return fechasUnicas.value.map((fecha, i) => {
|
||||||
const value = data.get(fecha) || 0
|
const value = data.get(fecha) || 0
|
||||||
const x = padding + (i / (fechasUnicas.value.length - 1 || 1)) * chartWidth
|
const x = padding + (i / (fechasUnicas.value.length - 1 || 1)) * chartWidth
|
||||||
const y = height - padding - (value / maxValue.value) * chartHeight
|
const y = height - padding - (value / maxValue.value) * chartHeight
|
||||||
|
|
||||||
|
// Parsear fecha según granularidad
|
||||||
|
let tempDate: Date
|
||||||
|
if (granularity === 'year') {
|
||||||
|
tempDate = new Date(parseInt(fecha), 0, 1)
|
||||||
|
} else if (granularity === 'month') {
|
||||||
|
const [year, month] = fecha.split('-')
|
||||||
|
tempDate = new Date(parseInt(year), parseInt(month) - 1, 1)
|
||||||
|
} else if (granularity === 'hour') {
|
||||||
|
// Formato: YYYY-MM-DDTHH
|
||||||
|
tempDate = new Date(fecha + ':00:00Z')
|
||||||
|
} else {
|
||||||
|
// granularity === 'day', formato: YYYY-MM-DD
|
||||||
|
tempDate = new Date(fecha + 'T00:00:00Z')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: fecha,
|
date: fecha,
|
||||||
value,
|
value,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
label: fecha
|
label: fecha,
|
||||||
|
dateLabel: formatDateByGranularity(tempDate, granularity)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<UCard class="brand-card border border-transparent">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3">
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-bold brand-section-title">Serie Temporal: Inversión Diaria</h3>
|
<div>
|
||||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
<h3 class="text-lg font-bold brand-section-title">Serie Temporal: Inversión Diaria</h3>
|
||||||
Cantidad invertida por día (en Lempiras)
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||||
</p>
|
Cantidad invertida por día (en Lempiras)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<UCheckbox
|
||||||
|
v-for="tipo in tipos"
|
||||||
|
:key="tipo.value"
|
||||||
|
:model-value="tiposSeleccionados.includes(tipo.value)"
|
||||||
|
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
||||||
|
:label="getTipoLabel(tipo)"
|
||||||
|
:disabled="isTipoDisabled(tipo.value)"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<UCheckbox
|
<!-- Selector de Granularidad Temporal -->
|
||||||
v-for="tipo in tipos"
|
<div class="flex items-center gap-2">
|
||||||
:key="tipo.value"
|
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Granularidad:</span>
|
||||||
:model-value="tiposSeleccionados.includes(tipo.value)"
|
<div class="flex items-center gap-1">
|
||||||
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
<button
|
||||||
:label="getTipoLabel(tipo)"
|
v-for="option in granularityOptions"
|
||||||
:disabled="isTipoDisabled(tipo.value)"
|
:key="option.value"
|
||||||
size="xs"
|
@click="selectedGranularity = option.value"
|
||||||
/>
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all',
|
||||||
|
selectedGranularity === option.value
|
||||||
|
? 'bg-[var(--brand-primary)] text-white shadow-sm'
|
||||||
|
: 'bg-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -63,16 +86,18 @@
|
|||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- X-axis labels -->
|
<!-- X-axis labels (distribuidos uniformemente) -->
|
||||||
<g v-for="(fecha, i) in fechasUnicas" :key="`label-${i}`">
|
<g v-if="tiposActivos.length > 0">
|
||||||
<text
|
<text
|
||||||
v-if="i % Math.ceil(fechasUnicas.length / 8) === 0 || i === fechasUnicas.length - 1"
|
v-for="(point, i) in getPointsForTipo(tiposActivos[0].value)"
|
||||||
:x="padding + (i / (fechasUnicas.length - 1 || 1)) * chartWidth + barWidth / 2"
|
:key="`label-${i}`"
|
||||||
|
v-show="shouldShowLabel(i, fechasUnicas.length)"
|
||||||
|
:x="point.x + barWidth / 2"
|
||||||
:y="height - padding + 20"
|
:y="height - padding + 20"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="text-xs fill-[var(--brand-text-muted)]"
|
class="text-xs fill-[var(--brand-text-muted)]"
|
||||||
>
|
>
|
||||||
{{ new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' }) }}
|
{{ point.dateLabel }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -122,6 +147,18 @@ const tiposSeleccionados = ref(['uva', 'verde', 'oreado', 'mojado'])
|
|||||||
|
|
||||||
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
||||||
|
|
||||||
|
// Selector de granularidad temporal
|
||||||
|
type GranularityMode = 'auto' | 'year' | 'month' | 'day' | 'hour'
|
||||||
|
const selectedGranularity = ref<GranularityMode>('auto')
|
||||||
|
|
||||||
|
const granularityOptions = [
|
||||||
|
{ value: 'auto' as GranularityMode, label: 'Auto', icon: 'i-lucide-wand-2' },
|
||||||
|
{ value: 'year' as GranularityMode, label: 'Años', icon: 'i-lucide-calendar' },
|
||||||
|
{ value: 'month' as GranularityMode, label: 'Meses', icon: 'i-lucide-calendar-days' },
|
||||||
|
{ value: 'day' as GranularityMode, label: 'Días', icon: 'i-lucide-calendar-clock' },
|
||||||
|
{ value: 'hour' as GranularityMode, label: 'Horas', icon: 'i-lucide-clock' }
|
||||||
|
]
|
||||||
|
|
||||||
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
||||||
const modoSeco = computed(() => {
|
const modoSeco = computed(() => {
|
||||||
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
||||||
@@ -183,19 +220,117 @@ interface DataPoint {
|
|||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
label: string
|
label: string
|
||||||
|
dateLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener todas las fechas únicas
|
// Función para detectar granularidad temporal
|
||||||
|
function getTimeGranularity(startDate: Date, endDate: Date): 'year' | 'month' | 'day' | 'hour' {
|
||||||
|
// Si no es auto, usar la selección manual
|
||||||
|
if (selectedGranularity.value !== 'auto') {
|
||||||
|
return selectedGranularity.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto: detectar según el rango
|
||||||
|
const diffMs = endDate.getTime() - startDate.getTime()
|
||||||
|
const diffDays = diffMs / (1000 * 60 * 60 * 24)
|
||||||
|
const diffMonths = diffDays / 30
|
||||||
|
const diffYears = diffDays / 365
|
||||||
|
|
||||||
|
if (diffYears > 2) return 'year'
|
||||||
|
if (diffMonths > 2) return 'month'
|
||||||
|
if (diffDays > 2) return 'day'
|
||||||
|
return 'hour'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para generar timestamps completos según granularidad
|
||||||
|
function generateTimeRange(startDate: Date, endDate: Date, granularity: 'year' | 'month' | 'day' | 'hour'): Date[] {
|
||||||
|
const dates: Date[] = []
|
||||||
|
const current = new Date(startDate)
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
dates.push(new Date(current))
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
current.setFullYear(current.getFullYear() + 1)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
current.setMonth(current.getMonth() + 1)
|
||||||
|
break
|
||||||
|
case 'day':
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
break
|
||||||
|
case 'hour':
|
||||||
|
current.setHours(current.getHours() + 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para formatear fecha según granularidad
|
||||||
|
function formatDateByGranularity(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return date.getFullYear().toString()
|
||||||
|
case 'month':
|
||||||
|
return date.toLocaleDateString('es-HN', { year: 'numeric', month: 'short' })
|
||||||
|
case 'day':
|
||||||
|
return date.toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
||||||
|
case 'hour':
|
||||||
|
return date.toLocaleTimeString('es-HN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para agrupar fecha según granularidad (devuelve formato ISO parseable)
|
||||||
|
function getDateKey(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
|
||||||
|
switch (granularity) {
|
||||||
|
case 'year':
|
||||||
|
return `${year}`
|
||||||
|
case 'month':
|
||||||
|
return `${year}-${month}`
|
||||||
|
case 'day':
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
case 'hour':
|
||||||
|
return `${year}-${month}-${day}T${hour}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar si mostrar una etiqueta (máximo 10 etiquetas en el eje)
|
||||||
|
function shouldShowLabel(index: number, total: number): boolean {
|
||||||
|
if (total <= 10) return true
|
||||||
|
|
||||||
|
const maxLabels = 10
|
||||||
|
const step = Math.ceil(total / maxLabels)
|
||||||
|
|
||||||
|
// Siempre mostrar primera y última
|
||||||
|
if (index === 0 || index === total - 1) return true
|
||||||
|
|
||||||
|
// Mostrar cada N puntos
|
||||||
|
return index % step === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener todas las fechas únicas (ahora generadas completas)
|
||||||
const fechasUnicas = computed(() => {
|
const fechasUnicas = computed(() => {
|
||||||
const fechas = new Set<string>()
|
const allDates = props.ingresos
|
||||||
props.ingresos
|
|
||||||
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
.forEach(i => {
|
.map(i => new Date(i.created_at!))
|
||||||
const fecha = new Date(i.created_at!).toLocaleDateString('es-HN')
|
|
||||||
fechas.add(fecha)
|
if (allDates.length === 0) return []
|
||||||
})
|
|
||||||
return Array.from(fechas).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
const timeRange = generateTimeRange(minDate, maxDate, granularity)
|
||||||
|
|
||||||
|
return timeRange.map(fecha => getDateKey(fecha, granularity))
|
||||||
})
|
})
|
||||||
|
|
||||||
const barWidth = computed(() => {
|
const barWidth = computed(() => {
|
||||||
@@ -206,6 +341,22 @@ const barWidth = computed(() => {
|
|||||||
const dataByTipo = computed(() => {
|
const dataByTipo = computed(() => {
|
||||||
const result: Record<string, Map<string, number>> = {}
|
const result: Record<string, Map<string, number>> = {}
|
||||||
|
|
||||||
|
const allDates = props.ingresos
|
||||||
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
|
.filter(i => i.created_at)
|
||||||
|
.map(i => new Date(i.created_at!))
|
||||||
|
|
||||||
|
if (allDates.length === 0) {
|
||||||
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
|
result[tipo] = new Map<string, number>()
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
|
||||||
tiposSeleccionados.value.forEach(tipo => {
|
tiposSeleccionados.value.forEach(tipo => {
|
||||||
result[tipo] = new Map<string, number>()
|
result[tipo] = new Map<string, number>()
|
||||||
|
|
||||||
@@ -213,7 +364,8 @@ const dataByTipo = computed(() => {
|
|||||||
.filter(i => i.tipo === tipo)
|
.filter(i => i.tipo === tipo)
|
||||||
.filter(i => i.created_at)
|
.filter(i => i.created_at)
|
||||||
.forEach(ingreso => {
|
.forEach(ingreso => {
|
||||||
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
|
const fecha = new Date(ingreso.created_at!)
|
||||||
|
const key = getDateKey(fecha, granularity)
|
||||||
|
|
||||||
// Calcular inversión según el tipo
|
// Calcular inversión según el tipo
|
||||||
let inversion = 0
|
let inversion = 0
|
||||||
@@ -223,7 +375,7 @@ const dataByTipo = computed(() => {
|
|||||||
inversion = (ingreso.precio / 2) * ingreso.peso_seco
|
inversion = (ingreso.precio / 2) * ingreso.peso_seco
|
||||||
}
|
}
|
||||||
|
|
||||||
result[tipo].set(fecha, (result[tipo].get(fecha) || 0) + inversion)
|
result[tipo].set(key, (result[tipo].get(key) || 0) + inversion)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,17 +406,44 @@ function getPointsForTipo(tipo: string): DataPoint[] {
|
|||||||
const data = dataByTipo.value[tipo]
|
const data = dataByTipo.value[tipo]
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
|
|
||||||
|
const allDates = props.ingresos
|
||||||
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
||||||
|
.filter(i => i.created_at)
|
||||||
|
.map(i => new Date(i.created_at!))
|
||||||
|
|
||||||
|
if (allDates.length === 0) return []
|
||||||
|
|
||||||
|
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
|
||||||
|
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
|
||||||
|
const granularity = getTimeGranularity(minDate, maxDate)
|
||||||
|
|
||||||
return fechasUnicas.value.map((fecha, i) => {
|
return fechasUnicas.value.map((fecha, i) => {
|
||||||
const value = data.get(fecha) || 0
|
const value = data.get(fecha) || 0
|
||||||
const x = padding + (i / (fechasUnicas.value.length - 1 || 1)) * chartWidth
|
const x = padding + (i / (fechasUnicas.value.length - 1 || 1)) * chartWidth
|
||||||
const y = height - padding - (value / maxValue.value) * chartHeight
|
const y = height - padding - (value / maxValue.value) * chartHeight
|
||||||
|
|
||||||
|
// Parsear fecha según granularidad
|
||||||
|
let tempDate: Date
|
||||||
|
if (granularity === 'year') {
|
||||||
|
tempDate = new Date(parseInt(fecha), 0, 1)
|
||||||
|
} else if (granularity === 'month') {
|
||||||
|
const [year, month] = fecha.split('-')
|
||||||
|
tempDate = new Date(parseInt(year), parseInt(month) - 1, 1)
|
||||||
|
} else if (granularity === 'hour') {
|
||||||
|
// Formato: YYYY-MM-DDTHH
|
||||||
|
tempDate = new Date(fecha + ':00:00Z')
|
||||||
|
} else {
|
||||||
|
// granularity === 'day', formato: YYYY-MM-DD
|
||||||
|
tempDate = new Date(fecha + 'T00:00:00Z')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: fecha,
|
date: fecha,
|
||||||
value,
|
value,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
label: fecha
|
label: fecha,
|
||||||
|
dateLabel: formatDateByGranularity(tempDate, granularity)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
67
nuxt4-app/app/composables/useInformeLayout.ts
Normal file
67
nuxt4-app/app/composables/useInformeLayout.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { inject, ref, type Ref, type Component, type VNode } from 'vue'
|
||||||
|
|
||||||
|
export interface FiltrosResumen {
|
||||||
|
count: number
|
||||||
|
summary: string
|
||||||
|
results: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatasourceCounts {
|
||||||
|
[key: string]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveFilter {
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
value: any
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInformeLayout() {
|
||||||
|
const setFiltrosResumenFn = inject<(resumen: FiltrosResumen | null) => void>('setFiltrosResumen')
|
||||||
|
const setDatasourceCountsFn = inject<(counts: DatasourceCounts) => void>('setDatasourceCounts')
|
||||||
|
const setFilteredResultsFn = inject<(results: DatasourceCounts) => void>('setFilteredResults')
|
||||||
|
const setActiveFiltersFn = inject<(filters: ActiveFilter[]) => void>('setActiveFilters')
|
||||||
|
const filtrosCollapsedRef = inject<Ref<boolean>>('filtrosCollapsed')
|
||||||
|
const metadatosCollapsedRef = inject<Ref<boolean>>('metadatosCollapsed')
|
||||||
|
|
||||||
|
function setFiltrosResumen(count: number, summary: string, results: number) {
|
||||||
|
if (setFiltrosResumenFn) {
|
||||||
|
setFiltrosResumenFn({ count, summary, results })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiltrosResumen() {
|
||||||
|
if (setFiltrosResumenFn) {
|
||||||
|
setFiltrosResumenFn(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDatasourceCounts(counts: DatasourceCounts) {
|
||||||
|
if (setDatasourceCountsFn) {
|
||||||
|
setDatasourceCountsFn(counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilteredResults(results: DatasourceCounts) {
|
||||||
|
if (setFilteredResultsFn) {
|
||||||
|
setFilteredResultsFn(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveFilters(filters: ActiveFilter[]) {
|
||||||
|
if (setActiveFiltersFn) {
|
||||||
|
setActiveFiltersFn(filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setFiltrosResumen,
|
||||||
|
clearFiltrosResumen,
|
||||||
|
setDatasourceCounts,
|
||||||
|
setFilteredResults,
|
||||||
|
setActiveFilters,
|
||||||
|
filtrosCollapsed: filtrosCollapsedRef || ref(false),
|
||||||
|
metadatosCollapsed: metadatosCollapsedRef || ref(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,77 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="brand-shell min-h-screen text-[#fef9f0]">
|
<div class="brand-shell min-h-screen text-[#fef9f0]">
|
||||||
<UDashboardGroup storage-key="analytics-dashboard" class="h-full">
|
<UDashboardGroup storage-key="analytics-dashboard" class="h-full">
|
||||||
<UDashboardSidebar
|
<AppSidebar v-model:open="sidebarOpen" v-model:collapsed="sidebarCollapsed" />
|
||||||
v-model:open="sidebarOpen"
|
|
||||||
v-model:collapsed="sidebarCollapsed"
|
|
||||||
collapsible
|
|
||||||
resizable
|
|
||||||
:default-size="28"
|
|
||||||
:min-size="20"
|
|
||||||
:max-size="38"
|
|
||||||
:toggle="{ color: 'primary', variant: 'subtle', class: 'rounded-full' }"
|
|
||||||
>
|
|
||||||
<template #header="{ collapsed }">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<img
|
|
||||||
v-if="!collapsed"
|
|
||||||
src="/logo.png"
|
|
||||||
alt="Analítica Núcleo"
|
|
||||||
class="h-8 w-8 rounded-full border border-[#ffe0a0]/40"
|
|
||||||
/>
|
|
||||||
<UIcon v-else name="i-lucide-activity" class="size-5 text-[#ffe0a0]" />
|
|
||||||
<span v-if="!collapsed" class="text-sm font-semibold text-[var(--brand-text)]">Analítica Núcleo</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #default="{ collapsed }">
|
|
||||||
<UButton
|
|
||||||
:label="collapsed ? undefined : 'Buscar...'"
|
|
||||||
icon="i-lucide-search"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
block
|
|
||||||
:square="collapsed"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<template v-if="!collapsed" #trailing>
|
|
||||||
<div class="flex items-center gap-0.5 ms-auto text-[var(--brand-text-muted)]">
|
|
||||||
<UKbd value="⌘" variant="subtle" />
|
|
||||||
<UKbd value="K" variant="subtle" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UNavigationMenu
|
|
||||||
:collapsed="collapsed"
|
|
||||||
:items="navigationPrimary"
|
|
||||||
orientation="vertical"
|
|
||||||
class="gap-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UNavigationMenu
|
|
||||||
:collapsed="collapsed"
|
|
||||||
:items="navigationSecondary"
|
|
||||||
orientation="vertical"
|
|
||||||
class="mt-auto gap-1"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #footer="{ collapsed }">
|
|
||||||
<UButton
|
|
||||||
:avatar="{ src: 'https://avatars.githubusercontent.com/u/12011070?v=4' }"
|
|
||||||
:label="collapsed ? undefined : 'Equipo Núcleo'"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="w-full justify-start"
|
|
||||||
:block="collapsed"
|
|
||||||
>
|
|
||||||
<template #trailing>
|
|
||||||
<UIcon name="i-lucide-log-out" class="size-4" />
|
|
||||||
</template>
|
|
||||||
</UButton>
|
|
||||||
</template>
|
|
||||||
</UDashboardSidebar>
|
|
||||||
|
|
||||||
<UDashboardPanel class="bg-transparent">
|
<UDashboardPanel class="bg-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -104,66 +34,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const sidebarOpen = ref(true)
|
const sidebarOpen = ref(true)
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
||||||
const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
|
||||||
{
|
|
||||||
label: 'Inicio',
|
|
||||||
icon: 'i-lucide-home',
|
|
||||||
to: '/',
|
|
||||||
active: route.path === '/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Panorama Facturador',
|
|
||||||
icon: 'i-lucide-bar-chart-3',
|
|
||||||
to: '/panorama',
|
|
||||||
active: route.path === '/panorama'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Informe Ingresos',
|
|
||||||
icon: 'i-lucide-file-bar-chart',
|
|
||||||
to: '/informe-ingresos',
|
|
||||||
active: route.path === '/informe-ingresos'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Explorador de datos',
|
|
||||||
icon: 'i-lucide-table',
|
|
||||||
to: '/explorer',
|
|
||||||
active: route.path === '/explorer'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Metadatos',
|
|
||||||
icon: 'i-lucide-database',
|
|
||||||
to: '/metadatos',
|
|
||||||
active: route.path === '/metadatos'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Explorador de datos raw',
|
|
||||||
icon: 'i-lucide-table',
|
|
||||||
to: '/rawExplorer',
|
|
||||||
active: route.path === '/rawExplorer'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const navigationSecondary: NavigationMenuItem[] = [
|
|
||||||
{
|
|
||||||
label: 'Documentación',
|
|
||||||
icon: 'i-lucide-book-open',
|
|
||||||
to: 'https://ui.nuxt.com',
|
|
||||||
target: '_blank'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Repositorio',
|
|
||||||
icon: 'i-lucide-github',
|
|
||||||
to: 'https://gitea.nucleoriofrio.com/nucleo000/analiticaNucleo',
|
|
||||||
target: '_blank'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const pageTitle = computed(() => (route.meta.title as string) || 'Panel')
|
const pageTitle = computed(() => (route.meta.title as string) || 'Panel')
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
224
nuxt4-app/app/layouts/informe.vue
Normal file
224
nuxt4-app/app/layouts/informe.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<template>
|
||||||
|
<div class="brand-shell min-h-screen text-[#fef9f0]">
|
||||||
|
<UDashboardGroup storage-key="analytics-dashboard" class="h-full">
|
||||||
|
<AppSidebar v-model:open="sidebarOpen" v-model:collapsed="sidebarCollapsed" />
|
||||||
|
|
||||||
|
<UDashboardPanel class="bg-transparent">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-col gap-4 px-4 py-4 lg:px-6">
|
||||||
|
<UDashboardNavbar :title="pageTitle" icon="i-lucide-file-bar-chart" toggle-side="left">
|
||||||
|
<template #leading>
|
||||||
|
<UDashboardSidebarCollapse variant="subtle" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #toggle>
|
||||||
|
<UDashboardSidebarToggle variant="subtle" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #trailing>
|
||||||
|
<!-- Botones de acciones rápidas con transiciones -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-x-4"
|
||||||
|
enter-to-class="opacity-100 translate-x-0"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-x-0"
|
||||||
|
leave-to-class="opacity-0 translate-x-4"
|
||||||
|
>
|
||||||
|
<div v-show="showActions" class="flex items-center gap-2">
|
||||||
|
<!-- Botón de Filtros -->
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
:icon="filtrosCollapsed ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
|
||||||
|
:label="filtrosResumen ? undefined : 'Filtros'"
|
||||||
|
@click="toggleFiltros"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<template v-if="filtrosResumen" #leading>
|
||||||
|
<UIcon name="i-lucide-filter" />
|
||||||
|
</template>
|
||||||
|
<template v-if="filtrosResumen" #trailing>
|
||||||
|
<UBadge
|
||||||
|
:label="filtrosResumen.count.toString()"
|
||||||
|
color="primary"
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Botón de Metadatos -->
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
:icon="metadatosCollapsed ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
|
||||||
|
@click="toggleMetadatos"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-database" />
|
||||||
|
</template>
|
||||||
|
<template #trailing>
|
||||||
|
<UBadge
|
||||||
|
v-if="totalDatasourceRecords > 0"
|
||||||
|
:label="totalDatasourceRecords.toString()"
|
||||||
|
color="primary"
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Resumen compacto de resultados y filtros activos -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Contadores -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-x-4 scale-95"
|
||||||
|
enter-to-class="opacity-100 translate-x-0 scale-100"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-x-0 scale-100"
|
||||||
|
leave-to-class="opacity-0 translate-x-4 scale-95"
|
||||||
|
>
|
||||||
|
<div v-if="Object.keys(filteredResults).length > 0" class="flex items-center gap-2">
|
||||||
|
<div class="h-4 w-px bg-[var(--brand-border)]"></div>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<div v-for="(count, name) in filteredResults" :key="name" class="flex items-center gap-1">
|
||||||
|
<span class="text-[var(--brand-text-muted)]">{{ name }}:</span>
|
||||||
|
<span class="font-semibold text-[var(--brand-primary)]">{{ count }}</span>
|
||||||
|
<span class="text-[var(--brand-text-muted)]">/</span>
|
||||||
|
<span class="font-medium text-[var(--brand-text-muted)]">{{ datasourceCounts[name] || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Filtros activos -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-x-4 scale-95"
|
||||||
|
enter-to-class="opacity-100 translate-x-0 scale-100"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-x-0 scale-100"
|
||||||
|
leave-to-class="opacity-0 translate-x-4 scale-95"
|
||||||
|
>
|
||||||
|
<div v-if="activeFilters.length > 0" class="flex items-center gap-2">
|
||||||
|
<div class="h-4 w-px bg-[var(--brand-border)]"></div>
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap max-w-xl">
|
||||||
|
<UBadge
|
||||||
|
v-for="(filter, index) in activeFilters"
|
||||||
|
:key="index"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
class="cursor-pointer hover:bg-[var(--brand-primary)]/20 transition-colors"
|
||||||
|
@click="filter.onRemove"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-[10px] font-medium">{{ filter.label }}</span>
|
||||||
|
<UIcon name="i-lucide-x" class="w-2.5 h-2.5" />
|
||||||
|
</div>
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="px-4 pb-10 lg:px-8">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDashboardPanel>
|
||||||
|
</UDashboardGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const sidebarOpen = ref(true)
|
||||||
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
||||||
|
const showActions = ref(true)
|
||||||
|
|
||||||
|
interface FiltrosResumen {
|
||||||
|
count: number
|
||||||
|
summary: string
|
||||||
|
results: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatasourceCounts {
|
||||||
|
[key: string]: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveFilter {
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
value: any
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado compartido para filtros y metadatos
|
||||||
|
const filtrosResumen = ref<FiltrosResumen | null>(null)
|
||||||
|
const datasourceCounts = ref<DatasourceCounts>({})
|
||||||
|
const filteredResults = ref<DatasourceCounts>({})
|
||||||
|
const activeFilters = ref<ActiveFilter[]>([])
|
||||||
|
|
||||||
|
// Estado colapsado de secciones
|
||||||
|
const filtrosCollapsed = ref(true)
|
||||||
|
const metadatosCollapsed = ref(true)
|
||||||
|
|
||||||
|
// Computed para el total de registros en datasources
|
||||||
|
const totalDatasourceRecords = computed(() => {
|
||||||
|
return Object.values(datasourceCounts.value).reduce((sum, count) => sum + count, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Provide para que las páginas puedan actualizar estos valores
|
||||||
|
provide('setFiltrosResumen', (resumen: FiltrosResumen | null) => {
|
||||||
|
filtrosResumen.value = resumen
|
||||||
|
})
|
||||||
|
|
||||||
|
provide('setDatasourceCounts', (counts: DatasourceCounts) => {
|
||||||
|
datasourceCounts.value = counts
|
||||||
|
})
|
||||||
|
|
||||||
|
provide('setFilteredResults', (results: DatasourceCounts) => {
|
||||||
|
filteredResults.value = results
|
||||||
|
})
|
||||||
|
|
||||||
|
provide('setActiveFilters', (filters: ActiveFilter[]) => {
|
||||||
|
activeFilters.value = filters
|
||||||
|
})
|
||||||
|
|
||||||
|
// Provide estado colapsado para que las páginas lo lean
|
||||||
|
provide('filtrosCollapsed', filtrosCollapsed)
|
||||||
|
provide('metadatosCollapsed', metadatosCollapsed)
|
||||||
|
|
||||||
|
const pageTitle = computed(() => (route.meta.title as string) || 'Informe')
|
||||||
|
|
||||||
|
function toggleFiltros() {
|
||||||
|
filtrosCollapsed.value = !filtrosCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMetadatos() {
|
||||||
|
metadatosCollapsed.value = !metadatosCollapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostrar acciones después de montar
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
showActions.value = true
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -28,11 +28,58 @@
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Metadatos Cards de Ingresos y Clientes -->
|
<!-- Metadatos Card Colapsable -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<Transition
|
||||||
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" />
|
enter-from-class="opacity-0 -translate-y-2"
|
||||||
</div>
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 -translate-y-2"
|
||||||
|
>
|
||||||
|
<div v-show="!metadatosCollapsed" class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
|
||||||
|
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Filtros Card Colapsable -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-300 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-2"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition-all duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 -translate-y-2"
|
||||||
|
>
|
||||||
|
<UCard v-show="!filtrosCollapsed" class="brand-card border border-transparent">
|
||||||
|
<InformeIngresosFiltrosPanel
|
||||||
|
:clientes="clientes"
|
||||||
|
:selected-cliente-ids="selectedClienteIds"
|
||||||
|
:selected-preset="selectedPreset"
|
||||||
|
:fecha-desde="fechaDesde"
|
||||||
|
:fecha-hasta="fechaHasta"
|
||||||
|
:selected-tipos="selectedTipos"
|
||||||
|
:selected-estados="selectedEstados"
|
||||||
|
:selected-ubicaciones="selectedUbicaciones"
|
||||||
|
:selected-calidades="selectedCalidades"
|
||||||
|
:tipos-options="tiposCafeOptions"
|
||||||
|
:estados-options="estadosOptions"
|
||||||
|
:ubicaciones-options="ubicacionesOptions"
|
||||||
|
:calidades-options="calidadesOptions"
|
||||||
|
:include-anulados="includeAnulados"
|
||||||
|
@update:selected-cliente-ids="selectedClienteIds = $event"
|
||||||
|
@update:selected-preset="selectedPreset = $event"
|
||||||
|
@update:fecha-desde="fechaDesde = $event"
|
||||||
|
@update:fecha-hasta="fechaHasta = $event"
|
||||||
|
@update:selected-tipos="selectedTipos = $event"
|
||||||
|
@update:selected-estados="selectedEstados = $event"
|
||||||
|
@update:selected-ubicaciones="selectedUbicaciones = $event"
|
||||||
|
@update:selected-calidades="selectedCalidades = $event"
|
||||||
|
@update:include-anulados="includeAnulados = $event"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Clientes Seleccionados Cards -->
|
<!-- Clientes Seleccionados Cards -->
|
||||||
<div v-if="clientesSeleccionados.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-if="clientesSeleccionados.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -46,242 +93,6 @@
|
|||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</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 y Configuraciones</h2>
|
|
||||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
|
||||||
Filtros aplicados a ingresos por fecha y cliente
|
|
||||||
</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 gap-6">
|
|
||||||
<!-- Fila 1: Selector de Clientes -->
|
|
||||||
<div class="flex items-end justify-between gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
|
||||||
Clientes
|
|
||||||
</h3>
|
|
||||||
<ClienteSelector
|
|
||||||
:clientes="clientes"
|
|
||||||
:selected-ids="selectedClienteIds"
|
|
||||||
@update:selected-ids="selectedClienteIds = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
v-if="selectedClienteIds.length > 0"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
@click="selectedClienteIds = []"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<UIcon name="i-lucide-x" />
|
|
||||||
</template>
|
|
||||||
Limpiar
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fila 2: Selector de Rango de Fechas -->
|
|
||||||
<div class="flex items-end justify-between gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
|
||||||
Rango de Fechas
|
|
||||||
</h3>
|
|
||||||
<DateRangeSelector
|
|
||||||
:selected-preset="selectedPreset"
|
|
||||||
:fecha-desde="fechaDesde"
|
|
||||||
:fecha-hasta="fechaHasta"
|
|
||||||
@update:selected-preset="selectedPreset = $event"
|
|
||||||
@update:fecha-desde="fechaDesde = $event"
|
|
||||||
@update:fecha-hasta="fechaHasta = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
v-if="fechaDesde || fechaHasta"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
@click="() => { fechaDesde = null; fechaHasta = null; selectedPreset = '' }"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<UIcon name="i-lucide-x" />
|
|
||||||
</template>
|
|
||||||
Limpiar
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fila 3: Filtros Avanzados -->
|
|
||||||
<div class="flex items-end justify-between gap-4">
|
|
||||||
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<!-- Tipos de Café -->
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
|
||||||
Tipos de Café
|
|
||||||
</h3>
|
|
||||||
<UInputMenu
|
|
||||||
v-model="selectedTipos"
|
|
||||||
:items="tiposCafeOptions"
|
|
||||||
value-key="value"
|
|
||||||
multiple
|
|
||||||
placeholder="Todos los tipos"
|
|
||||||
size="sm"
|
|
||||||
icon="i-lucide-coffee"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estados -->
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
|
||||||
Estados
|
|
||||||
</h3>
|
|
||||||
<UInputMenu
|
|
||||||
v-model="selectedEstados"
|
|
||||||
:items="estadosOptions"
|
|
||||||
value-key="value"
|
|
||||||
multiple
|
|
||||||
placeholder="Todos los estados"
|
|
||||||
size="sm"
|
|
||||||
icon="i-lucide-circle-check-big"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ubicaciones -->
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
|
||||||
Ubicaciones
|
|
||||||
</h3>
|
|
||||||
<UInputMenu
|
|
||||||
v-model="selectedUbicaciones"
|
|
||||||
:items="ubicacionesOptions"
|
|
||||||
value-key="value"
|
|
||||||
multiple
|
|
||||||
placeholder="Todas las ubicaciones"
|
|
||||||
size="sm"
|
|
||||||
icon="i-lucide-map-pin"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calidad -->
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
|
||||||
Calidad
|
|
||||||
</h3>
|
|
||||||
<UInputMenu
|
|
||||||
v-model="selectedCalidades"
|
|
||||||
:items="calidadesOptions"
|
|
||||||
value-key="value"
|
|
||||||
multiple
|
|
||||||
placeholder="Todas las calidades"
|
|
||||||
size="sm"
|
|
||||||
icon="i-lucide-star"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
v-if="hasAdvancedFilters"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
@click="clearAdvancedFilters"
|
|
||||||
class="shrink-0"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<UIcon name="i-lucide-x" />
|
|
||||||
</template>
|
|
||||||
Limpiar
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Main Stats - Highlighted -->
|
|
||||||
<div class="flex flex-wrap items-center gap-4 p-3 rounded-lg bg-gradient-to-r from-[var(--brand-primary)]/10 to-[var(--brand-primary)]/5 border border-[var(--brand-primary)]/20">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-calendar-range" class="w-4 h-4 text-[var(--brand-primary)]" />
|
|
||||||
<span class="text-sm font-semibold text-[var(--brand-text)]">
|
|
||||||
{{ rangoLegible }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-[var(--brand-border)]" />
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-trending-up" class="w-4 h-4 text-cyan-400" />
|
|
||||||
<span class="text-sm font-medium text-[var(--brand-text)]">
|
|
||||||
Ingresos:
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-bold text-cyan-400">
|
|
||||||
{{ ingresosFiltrados.length }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-[var(--brand-text-muted)]">
|
|
||||||
/ {{ ingresos.length }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-[var(--brand-border)]" />
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-users" class="w-4 h-4 text-yellow-500" />
|
|
||||||
<span class="text-sm font-medium text-[var(--brand-text)]">
|
|
||||||
Clientes:
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-bold text-yellow-500">
|
|
||||||
{{ selectedClienteIds.length > 0 ? selectedClienteIds.length : clientesFiltrados.length }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-[var(--brand-text-muted)]">
|
|
||||||
/ {{ clientes.length }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Filters -->
|
|
||||||
<div v-if="hasAdvancedFilters" class="flex flex-wrap gap-2 items-center text-xs">
|
|
||||||
<span class="text-[var(--brand-primary)] font-semibold">Filtros activos:</span>
|
|
||||||
<span v-if="selectedTipos.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
|
|
||||||
<UIcon name="i-lucide-coffee" class="w-3 h-3" />
|
|
||||||
{{ selectedTiposLabels }}
|
|
||||||
</span>
|
|
||||||
<span v-if="selectedEstados.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
|
|
||||||
<UIcon name="i-lucide-circle-check-big" class="w-3 h-3" />
|
|
||||||
{{ selectedEstadosLabels }}
|
|
||||||
</span>
|
|
||||||
<span v-if="selectedUbicaciones.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
|
|
||||||
<UIcon name="i-lucide-map-pin" class="w-3 h-3" />
|
|
||||||
{{ selectedUbicaciones.length }} ubicaciones
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Totales por Café -->
|
<!-- Totales por Café -->
|
||||||
<UCard class="brand-card border border-transparent">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -513,10 +324,13 @@ import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
|||||||
|
|
||||||
// Define page metadata
|
// Define page metadata
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'dashboard',
|
layout: 'informe',
|
||||||
title: 'Analizador Ingresos-Clientes'
|
title: 'Informe Ingresos'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Obtener estado colapsado desde el layout
|
||||||
|
const { filtrosCollapsed, metadatosCollapsed, setFiltrosResumen, setDatasourceCounts, setFilteredResults, setActiveFilters } = useInformeLayout()
|
||||||
|
|
||||||
// View modes with explicit hierarchy
|
// View modes with explicit hierarchy
|
||||||
type ViewMode = 'ingresos-only' | 'clientes-only' | 'ingresos-clientes' | 'clientes-ingresos'
|
type ViewMode = 'ingresos-only' | 'clientes-only' | 'ingresos-clientes' | 'clientes-ingresos'
|
||||||
const selectedView = ref<ViewMode>('ingresos-only')
|
const selectedView = ref<ViewMode>('ingresos-only')
|
||||||
@@ -984,6 +798,177 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Listener para escape key en fullscreen
|
// Listener para escape key en fullscreen
|
||||||
window.addEventListener('keydown', handleEscape)
|
window.addEventListener('keydown', handleEscape)
|
||||||
|
|
||||||
|
// Enviar counts de datasources al layout
|
||||||
|
setDatasourceCounts({
|
||||||
|
ingresos: ingresos.value.length,
|
||||||
|
clientes: clientes.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch para actualizar counts de datasources cuando cambien
|
||||||
|
watch([ingresos, clientes], () => {
|
||||||
|
setDatasourceCounts({
|
||||||
|
ingresos: ingresos.value.length,
|
||||||
|
clientes: clientes.value.length
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch para actualizar resultados filtrados en el navbar
|
||||||
|
watch([ingresosFiltrados, clientesFiltrados], () => {
|
||||||
|
setFilteredResults({
|
||||||
|
ingresos: ingresosFiltrados.value.length,
|
||||||
|
clientes: clientesFiltrados.value.length
|
||||||
|
})
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Actualizar resumen de filtros cuando cambien
|
||||||
|
watch([selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstados, selectedUbicaciones, selectedCalidades, includeAnulados], () => {
|
||||||
|
const filtrosActivos: string[] = []
|
||||||
|
|
||||||
|
if (selectedClienteIds.value.length > 0) {
|
||||||
|
filtrosActivos.push(`${selectedClienteIds.value.length} cliente${selectedClienteIds.value.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
if (fechaDesde.value || fechaHasta.value) {
|
||||||
|
filtrosActivos.push('rango de fechas')
|
||||||
|
}
|
||||||
|
if (selectedTipos.value.length > 0) {
|
||||||
|
filtrosActivos.push(`${selectedTipos.value.length} tipo${selectedTipos.value.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
if (selectedEstados.value.length > 0) {
|
||||||
|
filtrosActivos.push(`${selectedEstados.value.length} estado${selectedEstados.value.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
if (selectedUbicaciones.value.length > 0) {
|
||||||
|
filtrosActivos.push(`${selectedUbicaciones.value.length} ubicación${selectedUbicaciones.value.length > 1 ? 'es' : ''}`)
|
||||||
|
}
|
||||||
|
if (selectedCalidades.value.length > 0) {
|
||||||
|
filtrosActivos.push(`${selectedCalidades.value.length} calidad${selectedCalidades.value.length > 1 ? 'es' : ''}`)
|
||||||
|
}
|
||||||
|
if (includeAnulados.value) {
|
||||||
|
filtrosActivos.push('incluye anulados')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtrosActivos.length > 0) {
|
||||||
|
setFiltrosResumen(
|
||||||
|
filtrosActivos.length,
|
||||||
|
filtrosActivos.join(', '),
|
||||||
|
ingresosFiltrados.value.length
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setFiltrosResumen(0, '', ingresosFiltrados.value.length)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Actualizar filtros activos detallados para el navbar
|
||||||
|
watch([selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstados, selectedUbicaciones, selectedCalidades, includeAnulados, clientes], () => {
|
||||||
|
const filters: any[] = []
|
||||||
|
|
||||||
|
// Clientes seleccionados (mostrar cada cliente)
|
||||||
|
if (selectedClienteIds.value.length > 0) {
|
||||||
|
selectedClienteIds.value.forEach(id => {
|
||||||
|
const cliente = clientes.value?.find(c => c.id === id)
|
||||||
|
if (cliente) {
|
||||||
|
filters.push({
|
||||||
|
type: 'cliente',
|
||||||
|
label: `👤 ${cliente.name}`,
|
||||||
|
value: id,
|
||||||
|
onRemove: () => {
|
||||||
|
selectedClienteIds.value = selectedClienteIds.value.filter(cid => cid !== id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rango de fechas
|
||||||
|
if (fechaDesde.value || fechaHasta.value) {
|
||||||
|
filters.push({
|
||||||
|
type: 'fechas',
|
||||||
|
label: `📅 ${fechaDesde.value || '—'} → ${fechaHasta.value || '—'}`,
|
||||||
|
value: { desde: fechaDesde.value, hasta: fechaHasta.value },
|
||||||
|
onRemove: () => {
|
||||||
|
fechaDesde.value = null
|
||||||
|
fechaHasta.value = null
|
||||||
|
selectedPreset.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos de café
|
||||||
|
if (selectedTipos.value.length > 0) {
|
||||||
|
selectedTipos.value.forEach(tipo => {
|
||||||
|
const tipoObj = tiposCafeOptions.find(t => t.value === tipo)
|
||||||
|
if (tipoObj) {
|
||||||
|
filters.push({
|
||||||
|
type: 'tipo',
|
||||||
|
label: `☕ ${tipoObj.label}`,
|
||||||
|
value: tipo,
|
||||||
|
onRemove: () => {
|
||||||
|
selectedTipos.value = selectedTipos.value.filter(t => t !== tipo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estados
|
||||||
|
if (selectedEstados.value.length > 0) {
|
||||||
|
selectedEstados.value.forEach(estado => {
|
||||||
|
const estadoObj = estadosOptions.find(e => e.value === estado)
|
||||||
|
if (estadoObj) {
|
||||||
|
filters.push({
|
||||||
|
type: 'estado',
|
||||||
|
label: `✓ ${estadoObj.label}`,
|
||||||
|
value: estado,
|
||||||
|
onRemove: () => {
|
||||||
|
selectedEstados.value = selectedEstados.value.filter(e => e !== estado)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ubicaciones
|
||||||
|
if (selectedUbicaciones.value.length > 0) {
|
||||||
|
selectedUbicaciones.value.forEach(ubicacion => {
|
||||||
|
filters.push({
|
||||||
|
type: 'ubicacion',
|
||||||
|
label: `📍 ${ubicacion}`,
|
||||||
|
value: ubicacion,
|
||||||
|
onRemove: () => {
|
||||||
|
selectedUbicaciones.value = selectedUbicaciones.value.filter(u => u !== ubicacion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calidades
|
||||||
|
if (selectedCalidades.value.length > 0) {
|
||||||
|
selectedCalidades.value.forEach(calidad => {
|
||||||
|
filters.push({
|
||||||
|
type: 'calidad',
|
||||||
|
label: `⭐ ${calidad}`,
|
||||||
|
value: calidad,
|
||||||
|
onRemove: () => {
|
||||||
|
selectedCalidades.value = selectedCalidades.value.filter(c => c !== calidad)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incluir anulados
|
||||||
|
if (includeAnulados.value) {
|
||||||
|
filters.push({
|
||||||
|
type: 'anulados',
|
||||||
|
label: '⚠️ Con anulados',
|
||||||
|
value: true,
|
||||||
|
onRemove: () => {
|
||||||
|
includeAnulados.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveFilters(filters)
|
||||||
|
}, { immediate: true })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ import type { RechazoRecord } from '~/composables/useRechazosMetrics'
|
|||||||
|
|
||||||
// Define page metadata
|
// Define page metadata
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'dashboard',
|
layout: 'informe',
|
||||||
title: 'Panorama Facturador'
|
title: 'Panorama Facturador'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user