Files
seguidorDeLotes/nuxt4/app/components/vinculaciones/ProgressDashboard.vue
josedario87 ce8bad68d5
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
Agregar sistema de vinculaciones con registros externos de Metabase
- Nuevo schema BD para vinculaciones_externas con constraint único por período
- Cliente Metabase para consultar Ingresos, Carretas, Salidas y Rechazos
- Endpoints API para registros externos (/api/externos/*) y vinculaciones (/api/vinculaciones/*)
- Composable useRegistrosExternos con lógica de vinculación individual y masiva
- Componentes: TablaRegistros, ModalAsignar, ProgressDashboard
- Tab "Externos" en app.vue con sub-tabs y dashboard de progreso
- LotesCard.vue ahora muestra registros vinculados al lote
2025-11-29 15:25:26 -06:00

216 lines
7.7 KiB
Vue

<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h2 class="text-2xl font-bold">Dashboard de Vinculación</h2>
<p class="text-gray-500">Progreso de vinculación de registros a lotes</p>
</div>
<div class="flex gap-2 items-center">
<USelect
v-model="periodoSeleccionado"
:items="PERIODOS_COSECHA"
label-key="label"
value-key="value"
class="w-80"
/>
<UButton
icon="i-heroicons-arrow-path"
variant="outline"
:loading="loading"
@click="cargarEstadisticas"
/>
</div>
</div>
<!-- Cards de resumen -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<!-- Card por tipo -->
<UCard
v-for="tipo in tiposConEstadisticas"
:key="tipo.value"
class="cursor-pointer hover:shadow-lg transition-shadow"
@click="$emit('seleccionar-tipo', tipo.value)"
>
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2 mb-2">
<UIcon :name="tipo.icon" class="w-5 h-5" :class="`text-${tipo.color}-500`" />
<span class="font-medium">{{ tipo.label }}</span>
</div>
<div class="text-3xl font-bold mb-1">
{{ tipo.stats?.vinculados || 0 }}
<span class="text-lg text-gray-400 font-normal">/ {{ tipo.stats?.total || 0 }}</span>
</div>
<p class="text-sm text-gray-500">
{{ tipo.stats?.sinVincular || 0 }} pendientes
</p>
</div>
<div class="text-right">
<div
class="text-2xl font-bold"
:class="{
'text-green-500': (tipo.stats?.porcentaje || 0) >= 80,
'text-yellow-500': (tipo.stats?.porcentaje || 0) >= 50 && (tipo.stats?.porcentaje || 0) < 80,
'text-red-500': (tipo.stats?.porcentaje || 0) < 50
}"
>
{{ tipo.stats?.porcentaje || 0 }}%
</div>
</div>
</div>
<!-- Barra de progreso -->
<div class="mt-3 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-500"
:class="`bg-${tipo.color}-500`"
:style="{ width: `${tipo.stats?.porcentaje || 0}%` }"
/>
</div>
</UCard>
<!-- Card resumen total -->
<UCard class="bg-gray-50 dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2 mb-2">
<UIcon name="i-heroicons-chart-pie" class="w-5 h-5 text-gray-600" />
<span class="font-medium">Total General</span>
</div>
<div class="text-3xl font-bold mb-1">
{{ estadisticas?.resumen?.vinculados || 0 }}
<span class="text-lg text-gray-400 font-normal">/ {{ estadisticas?.resumen?.total || 0 }}</span>
</div>
<p class="text-sm text-gray-500">
{{ estadisticas?.resumen?.sinVincular || 0 }} pendientes
</p>
</div>
<div class="text-right">
<div
class="text-3xl font-bold"
:class="{
'text-green-500': (estadisticas?.resumen?.porcentaje || 0) >= 80,
'text-yellow-500': (estadisticas?.resumen?.porcentaje || 0) >= 50 && (estadisticas?.resumen?.porcentaje || 0) < 80,
'text-red-500': (estadisticas?.resumen?.porcentaje || 0) < 50
}"
>
{{ estadisticas?.resumen?.porcentaje || 0 }}%
</div>
</div>
</div>
<!-- Barra de progreso -->
<div class="mt-3 w-full bg-gray-300 dark:bg-gray-600 rounded-full h-3">
<div
class="h-3 rounded-full transition-all duration-500 bg-gradient-to-r from-purple-500 via-blue-500 to-green-500"
:style="{ width: `${estadisticas?.resumen?.porcentaje || 0}%` }"
/>
</div>
</UCard>
</div>
<!-- Gráfico de barras horizontal -->
<UCard>
<template #header>
<h3 class="font-semibold">Progreso por tipo de registro</h3>
</template>
<div class="space-y-4">
<div
v-for="tipo in tiposConEstadisticas"
:key="tipo.value"
class="flex items-center gap-4"
>
<div class="w-24 flex items-center gap-2">
<UIcon :name="tipo.icon" class="w-4 h-4" :class="`text-${tipo.color}-500`" />
<span class="text-sm">{{ tipo.label }}</span>
</div>
<div class="flex-1">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4 relative">
<div
class="h-4 rounded-full transition-all duration-500 flex items-center justify-end pr-2"
:class="`bg-${tipo.color}-500`"
:style="{ width: `${Math.max(tipo.stats?.porcentaje || 0, 5)}%` }"
>
<span v-if="(tipo.stats?.porcentaje || 0) > 10" class="text-xs text-white font-medium">
{{ tipo.stats?.vinculados || 0 }}
</span>
</div>
<span
v-if="(tipo.stats?.porcentaje || 0) <= 10"
class="absolute left-2 top-0 h-4 flex items-center text-xs font-medium text-gray-600"
>
{{ tipo.stats?.vinculados || 0 }}
</span>
</div>
</div>
<div class="w-20 text-right text-sm text-gray-500">
de {{ tipo.stats?.total || 0 }}
</div>
</div>
</div>
</UCard>
<!-- Estado de carga -->
<div v-if="loading" class="flex justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-gray-400" />
</div>
<!-- Error -->
<UCard v-if="error" class="bg-red-50 dark:bg-red-950 border-red-500">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-exclamation-triangle" class="w-6 h-6 text-red-600" />
<div>
<h3 class="font-semibold text-red-600">Error cargando estadísticas</h3>
<p class="text-sm text-red-700 dark:text-red-400">{{ error }}</p>
</div>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
import type { EstadisticasVinculacion, TipoRegistro } from '~/composables/useRegistrosExternos'
const emit = defineEmits<{
'seleccionar-tipo': [tipo: TipoRegistro]
}>()
const { fetchEstadisticas, TIPOS_REGISTRO, PERIODOS_COSECHA } = useRegistrosExternos()
const periodoSeleccionado = ref('25-26')
const estadisticas = ref<EstadisticasVinculacion | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const tiposConEstadisticas = computed(() =>
TIPOS_REGISTRO.map(tipo => ({
...tipo,
stats: estadisticas.value?.[tipo.value === 'carreta' ? 'carretas' : tipo.value === 'ingreso' ? 'ingresos' : tipo.value === 'salida' ? 'salidas' : 'rechazos'] as any,
}))
)
const cargarEstadisticas = async () => {
loading.value = true
error.value = null
try {
const result = await fetchEstadisticas(periodoSeleccionado.value)
estadisticas.value = result
} catch (err: any) {
error.value = err.message || 'Error cargando estadísticas'
console.error('Error cargando estadísticas:', err)
} finally {
loading.value = false
}
}
// Cargar al montar
onMounted(() => {
cargarEstadisticas()
})
// Recargar cuando cambia el período
watch(periodoSeleccionado, () => {
cargarEstadisticas()
})
</script>