Agregar sistema de vinculaciones con registros externos de Metabase
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
- 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
This commit is contained in:
233
nuxt4/app/components/externos/TablaRegistros.vue
Normal file
233
nuxt4/app/components/externos/TablaRegistros.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Header con filtros -->
|
||||
<div class="flex flex-wrap gap-4 items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="tipoConfig.icon" class="w-6 h-6" :class="`text-${tipoConfig.color}-500`" />
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ tipoConfig.label }}</h3>
|
||||
<p v-if="meta" class="text-sm text-gray-500">
|
||||
{{ meta.vinculados }} de {{ meta.total }} vinculados ({{ meta.sinVincular }} pendientes)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<UCheckbox v-model="soloSinVincular" label="Solo sin vincular" />
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
label="Refrescar"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="seleccionados.length > 0"
|
||||
icon="i-heroicons-link"
|
||||
:label="`Vincular ${seleccionados.length}`"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="$emit('vincular-seleccionados', seleccionados)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de progreso -->
|
||||
<div v-if="meta" class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="`bg-${tipoConfig.color}-500`"
|
||||
:style="{ width: `${(meta.vinculados / Math.max(meta.total, 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tabla -->
|
||||
<UCard>
|
||||
<UTable
|
||||
v-model:selected="seleccionados"
|
||||
:data="registros"
|
||||
:columns="columnsWithSelect"
|
||||
:loading="loading"
|
||||
:empty-state="{
|
||||
icon: 'i-heroicons-inbox',
|
||||
label: 'No hay registros'
|
||||
}"
|
||||
>
|
||||
<!-- Columna de selección -->
|
||||
<template #select-header>
|
||||
<UCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</template>
|
||||
<template #select-cell="{ row }">
|
||||
<UCheckbox
|
||||
:model-value="isSelected(row.original)"
|
||||
:disabled="row.original.vinculado"
|
||||
@update:model-value="toggleSelection(row.original)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Estado de vinculación -->
|
||||
<template #vinculado-cell="{ getValue }">
|
||||
<UBadge v-if="getValue()" color="green" variant="subtle">
|
||||
<UIcon name="i-heroicons-check" class="w-3 h-3 mr-1" />
|
||||
Vinculado
|
||||
</UBadge>
|
||||
<UBadge v-else color="yellow" variant="subtle">
|
||||
<UIcon name="i-heroicons-clock" class="w-3 h-3 mr-1" />
|
||||
Pendiente
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<!-- Fecha -->
|
||||
<template #created_at-cell="{ getValue }">
|
||||
{{ formatFecha(getValue()) }}
|
||||
</template>
|
||||
|
||||
<!-- Peso (ingresos) -->
|
||||
<template #peso_seco-cell="{ getValue }">
|
||||
<span v-if="getValue() !== null && getValue() !== undefined" class="font-medium">
|
||||
{{ formatPeso(getValue()) }} qqSeco
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- Peso (carretas) -->
|
||||
<template #qq_seco_estimado-cell="{ getValue }">
|
||||
<span v-if="getValue() !== null && getValue() !== undefined" class="font-medium">
|
||||
{{ formatPeso(getValue()) }} qqSeco (est.)
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- Peso (salidas) -->
|
||||
<template #qq_seco-cell="{ getValue }">
|
||||
<span v-if="getValue() !== null && getValue() !== undefined" class="font-medium">
|
||||
{{ formatPeso(getValue()) }} qqSeco
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- Cantidad (rechazos) -->
|
||||
<template #cantidad-cell="{ row }">
|
||||
<span class="font-medium">
|
||||
{{ row.original.cantidad }} {{ row.original.unidad }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Acciones -->
|
||||
<template #actions-cell="{ row }">
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-if="!row.original.vinculado"
|
||||
icon="i-heroicons-link"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
@click="$emit('vincular', row.original)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-eye"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@click="$emit('ver-detalle', row.original)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
import type { TipoRegistro } from '~/composables/useRegistrosExternos'
|
||||
|
||||
const props = defineProps<{
|
||||
tipo: TipoRegistro
|
||||
registros: any[]
|
||||
loading: boolean
|
||||
meta?: {
|
||||
total: number
|
||||
vinculados: number
|
||||
sinVincular: number
|
||||
} | null
|
||||
columns: ColumnDef<any>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
vincular: [registro: any]
|
||||
'vincular-seleccionados': [registros: any[]]
|
||||
'ver-detalle': [registro: any]
|
||||
'update:solo-sin-vincular': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { TIPOS_REGISTRO, formatFecha, formatPeso } = useRegistrosExternos()
|
||||
|
||||
// Config del tipo
|
||||
const tipoConfig = computed(() => {
|
||||
const found = TIPOS_REGISTRO.find(t => t.value === props.tipo)
|
||||
return found || { value: props.tipo, label: props.tipo, icon: 'i-heroicons-document', color: 'gray' }
|
||||
})
|
||||
|
||||
// Filtro de solo sin vincular
|
||||
const soloSinVincular = ref(false)
|
||||
watch(soloSinVincular, (val) => {
|
||||
emit('update:solo-sin-vincular', val)
|
||||
})
|
||||
|
||||
// Selección múltiple
|
||||
const seleccionados = ref<any[]>([])
|
||||
|
||||
const registrosSeleccionables = computed(() =>
|
||||
props.registros.filter(r => !r.vinculado)
|
||||
)
|
||||
|
||||
const allSelected = computed(() =>
|
||||
registrosSeleccionables.value.length > 0 &&
|
||||
registrosSeleccionables.value.every(r => isSelected(r))
|
||||
)
|
||||
|
||||
const someSelected = computed(() =>
|
||||
seleccionados.value.length > 0
|
||||
)
|
||||
|
||||
const isSelected = (registro: any) => {
|
||||
return seleccionados.value.some(s => s.id === registro.id)
|
||||
}
|
||||
|
||||
const toggleSelection = (registro: any) => {
|
||||
if (registro.vinculado) return
|
||||
const index = seleccionados.value.findIndex(s => s.id === registro.id)
|
||||
if (index >= 0) {
|
||||
seleccionados.value.splice(index, 1)
|
||||
} else {
|
||||
seleccionados.value.push(registro)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected.value) {
|
||||
seleccionados.value = []
|
||||
} else {
|
||||
seleccionados.value = [...registrosSeleccionables.value]
|
||||
}
|
||||
}
|
||||
|
||||
// Agregar columna de selección al inicio
|
||||
const columnsWithSelect = computed<ColumnDef<any>[]>(() => [
|
||||
{ id: 'select', header: '', size: 40 },
|
||||
...props.columns,
|
||||
{ id: 'vinculado', accessorKey: 'vinculado', header: 'Estado' },
|
||||
{ id: 'actions', header: 'Acciones' },
|
||||
])
|
||||
|
||||
// Limpiar selección cuando cambian los datos
|
||||
watch(() => props.registros, () => {
|
||||
seleccionados.value = []
|
||||
})
|
||||
</script>
|
||||
@@ -53,6 +53,85 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sección de Vinculaciones Externas -->
|
||||
<div class="pt-4 border-t">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-gray-500 font-medium">Registros Externos Vinculados</p>
|
||||
<UButton
|
||||
v-if="!vinculacionesLoading && !vinculacionesCargadas"
|
||||
icon="i-heroicons-link"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
label="Cargar vinculaciones"
|
||||
@click="cargarVinculaciones"
|
||||
/>
|
||||
<UButton
|
||||
v-if="vinculacionesCargadas"
|
||||
icon="i-heroicons-arrow-path"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@click="cargarVinculaciones"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="vinculacionesLoading" class="flex items-center gap-2 text-gray-500">
|
||||
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin" />
|
||||
<span class="text-sm">Cargando vinculaciones...</span>
|
||||
</div>
|
||||
|
||||
<!-- Contenido de vinculaciones -->
|
||||
<div v-else-if="vinculacionesCargadas">
|
||||
<!-- Sin vinculaciones -->
|
||||
<div v-if="vinculacionesMeta?.total === 0" class="text-center py-4 text-gray-400">
|
||||
<UIcon name="i-heroicons-link-slash" class="w-8 h-8 mx-auto mb-2" />
|
||||
<p class="text-sm">No hay registros externos vinculados</p>
|
||||
</div>
|
||||
|
||||
<!-- Con vinculaciones -->
|
||||
<div v-else class="space-y-3">
|
||||
<!-- Resumen por tipo -->
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<div
|
||||
v-for="(count, tipo) in vinculacionesMeta?.por_tipo"
|
||||
:key="tipo"
|
||||
class="text-center p-2 rounded-lg bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<UIcon :name="getTipoRegistroIcon(tipo as string)" class="w-5 h-5 mx-auto mb-1" :class="`text-${getTipoRegistroColor(tipo as string)}-500`" />
|
||||
<p class="text-lg font-bold">{{ count }}</p>
|
||||
<p class="text-xs text-gray-500 capitalize">{{ tipo }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de vinculaciones agrupadas -->
|
||||
<div class="space-y-2">
|
||||
<template v-for="(items, tipo) in vinculacionesAgrupados" :key="tipo">
|
||||
<div v-if="items && items.length > 0" class="border rounded-lg p-2">
|
||||
<p class="text-xs font-medium text-gray-500 mb-1 capitalize flex items-center gap-1">
|
||||
<UIcon :name="getTipoRegistroIcon(tipo as string)" class="w-3 h-3" />
|
||||
{{ tipo }} ({{ items.length }})
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<UBadge
|
||||
v-for="v in items.slice(0, 5)"
|
||||
:key="v.id"
|
||||
:color="getTipoRegistroColor(tipo as string)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
#{{ v.registro_id }}
|
||||
</UBadge>
|
||||
<UBadge v-if="items.length > 5" color="gray" variant="subtle" size="xs">
|
||||
+{{ items.length - 5 }} más
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="lote.meta" class="pt-4 border-t">
|
||||
<p class="text-sm text-gray-500 mb-2">Información Adicional</p>
|
||||
<UCard class="bg-gray-50 dark:bg-slate-900/70 border border-gray-200 dark:border-slate-800">
|
||||
@@ -67,6 +146,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Lote } from '~/composables/useLotes'
|
||||
import type { Vinculacion } from '~/composables/useRegistrosExternos'
|
||||
|
||||
const props = defineProps<{
|
||||
lote: Lote
|
||||
@@ -78,6 +158,41 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { TIPOS_LOTE } = useLotes()
|
||||
const { fetchVinculacionesByLote, getTipoIcon, getTipoColor: getTipoRegistroColor } = useRegistrosExternos()
|
||||
|
||||
// Estado de vinculaciones
|
||||
const vinculacionesLoading = ref(false)
|
||||
const vinculacionesCargadas = ref(false)
|
||||
const vinculacionesAgrupados = ref<{
|
||||
ingresos: Vinculacion[]
|
||||
carretas: Vinculacion[]
|
||||
salidas: Vinculacion[]
|
||||
rechazos: Vinculacion[]
|
||||
} | null>(null)
|
||||
const vinculacionesMeta = ref<{
|
||||
lote_id: string
|
||||
total: number
|
||||
por_tipo: {
|
||||
ingresos: number
|
||||
carretas: number
|
||||
salidas: number
|
||||
rechazos: number
|
||||
}
|
||||
} | null>(null)
|
||||
|
||||
const cargarVinculaciones = async () => {
|
||||
vinculacionesLoading.value = true
|
||||
try {
|
||||
const result = await fetchVinculacionesByLote(props.lote.id)
|
||||
vinculacionesAgrupados.value = result.agrupados || null
|
||||
vinculacionesMeta.value = result.meta || null
|
||||
vinculacionesCargadas.value = true
|
||||
} catch (error) {
|
||||
console.error('Error cargando vinculaciones:', error)
|
||||
} finally {
|
||||
vinculacionesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getTipoLabel = (tipo: string) => {
|
||||
const found = TIPOS_LOTE.find((t) => t.value === tipo)
|
||||
@@ -98,6 +213,16 @@ const getTipoColor = (tipo: string): string => {
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const getTipoRegistroIcon = (tipo: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
ingresos: 'i-heroicons-inbox-arrow-down',
|
||||
carretas: 'i-heroicons-truck',
|
||||
salidas: 'i-heroicons-arrow-up-tray',
|
||||
rechazos: 'i-heroicons-x-circle',
|
||||
}
|
||||
return iconMap[tipo] || 'i-heroicons-document'
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-AR', {
|
||||
day: '2-digit',
|
||||
@@ -107,4 +232,9 @@ const formatDate = (dateString: string) => {
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// Cargar vinculaciones automáticamente al montar
|
||||
onMounted(() => {
|
||||
cargarVinculaciones()
|
||||
})
|
||||
</script>
|
||||
|
||||
257
nuxt4/app/components/vinculaciones/ModalAsignar.vue
Normal file
257
nuxt4/app/components/vinculaciones/ModalAsignar.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :title="titulo" class="max-w-2xl">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<!-- Resumen de registros a vincular -->
|
||||
<UCard v-if="registros.length > 0" class="bg-gray-50 dark:bg-gray-800">
|
||||
<div class="text-sm">
|
||||
<p class="font-medium mb-2">
|
||||
Registros a vincular ({{ registros.length }}):
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UBadge
|
||||
v-for="reg in registros.slice(0, 5)"
|
||||
:key="reg.id"
|
||||
:color="tipoColor"
|
||||
variant="subtle"
|
||||
>
|
||||
#{{ reg.id }}
|
||||
</UBadge>
|
||||
<UBadge v-if="registros.length > 5" color="gray" variant="subtle">
|
||||
+{{ registros.length - 5 }} más
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Selector de lote -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Seleccionar lote destino</label>
|
||||
<USelect
|
||||
v-model="loteSeleccionado"
|
||||
:items="lotesOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
searchable
|
||||
search-placeholder="Buscar por código..."
|
||||
placeholder="Selecciona un lote"
|
||||
class="w-full"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="getLoteColor(option.tipo)" size="xs" variant="subtle">
|
||||
{{ option.tipo }}
|
||||
</UBadge>
|
||||
<span class="font-mono">{{ option.codigo || 'Sin código' }}</span>
|
||||
<span v-if="option.cantidad_kg" class="text-gray-500 text-xs">
|
||||
({{ option.cantidad_kg }} kg)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</USelect>
|
||||
</div>
|
||||
|
||||
<!-- O crear nuevo lote -->
|
||||
<div class="text-center text-sm text-gray-500">
|
||||
<span>O</span>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
label="Crear nuevo lote"
|
||||
variant="outline"
|
||||
block
|
||||
@click="mostrarFormNuevoLote = !mostrarFormNuevoLote"
|
||||
/>
|
||||
|
||||
<!-- Form para nuevo lote -->
|
||||
<div v-if="mostrarFormNuevoLote" class="border rounded-lg p-4 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Código (opcional)">
|
||||
<UInput v-model="nuevoLote.codigo" placeholder="ej: LOTE-001" />
|
||||
</UFormField>
|
||||
<UFormField label="Tipo" required>
|
||||
<USelect
|
||||
v-model="nuevoLote.tipo"
|
||||
:items="TIPOS_LOTE"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="Selecciona tipo"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Cantidad (kg)">
|
||||
<UInput v-model.number="nuevoLote.cantidad_kg" type="number" placeholder="0.00" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Observaciones -->
|
||||
<UFormField label="Observaciones (opcional)">
|
||||
<UTextarea
|
||||
v-model="observaciones"
|
||||
placeholder="Notas sobre esta vinculación..."
|
||||
rows="2"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
label="Cancelar"
|
||||
variant="outline"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-link"
|
||||
:label="registros.length > 1 ? `Vincular ${registros.length} registros` : 'Vincular'"
|
||||
color="primary"
|
||||
:loading="guardando"
|
||||
:disabled="!puedeGuardar"
|
||||
@click="guardar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TipoRegistro, Lote } from '~/composables/useRegistrosExternos'
|
||||
|
||||
const props = defineProps<{
|
||||
tipo: TipoRegistro
|
||||
registros: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'vinculado': []
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { vincular, vincularMasivo, getTipoColor } = useRegistrosExternos()
|
||||
const { fetchLotes, createLote, TIPOS_LOTE } = useLotes()
|
||||
|
||||
// Estado
|
||||
const lotes = ref<Lote[]>([])
|
||||
const loteSeleccionado = ref<string | null>(null)
|
||||
const mostrarFormNuevoLote = ref(false)
|
||||
const nuevoLote = ref({
|
||||
codigo: '',
|
||||
tipo: '',
|
||||
cantidad_kg: null as number | null,
|
||||
})
|
||||
const observaciones = ref('')
|
||||
const guardando = ref(false)
|
||||
|
||||
// Computed
|
||||
const titulo = computed(() => {
|
||||
const tipoLabel = props.tipo.charAt(0).toUpperCase() + props.tipo.slice(1)
|
||||
return props.registros.length > 1
|
||||
? `Vincular ${props.registros.length} ${tipoLabel}s a lote`
|
||||
: `Vincular ${tipoLabel} a lote`
|
||||
})
|
||||
|
||||
const tipoColor = computed(() => getTipoColor(props.tipo))
|
||||
|
||||
const lotesOptions = computed(() =>
|
||||
lotes.value.map(l => ({
|
||||
value: l.id,
|
||||
label: l.codigo || `Lote ${l.id.slice(0, 8)}...`,
|
||||
codigo: l.codigo,
|
||||
tipo: l.tipo,
|
||||
cantidad_kg: l.cantidad_kg,
|
||||
}))
|
||||
)
|
||||
|
||||
const puedeGuardar = computed(() => {
|
||||
if (mostrarFormNuevoLote.value) {
|
||||
return nuevoLote.value.tipo !== ''
|
||||
}
|
||||
return loteSeleccionado.value !== null
|
||||
})
|
||||
|
||||
// Funciones
|
||||
const getLoteColor = (tipo: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
uva: 'purple',
|
||||
despulpado_primera: 'green',
|
||||
despulpado_segunda: 'yellow',
|
||||
despulpado_rechazos: 'red',
|
||||
oreado: 'orange',
|
||||
presecado: 'amber',
|
||||
reposo: 'blue',
|
||||
secado: 'emerald',
|
||||
}
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const cargarLotes = async () => {
|
||||
const resultado = await fetchLotes({ limit: 100 })
|
||||
lotes.value = resultado
|
||||
}
|
||||
|
||||
const guardar = async () => {
|
||||
guardando.value = true
|
||||
|
||||
try {
|
||||
let loteId = loteSeleccionado.value
|
||||
|
||||
// Si se quiere crear un nuevo lote
|
||||
if (mostrarFormNuevoLote.value && nuevoLote.value.tipo) {
|
||||
const nuevoLoteCreado = await createLote({
|
||||
codigo: nuevoLote.value.codigo || undefined,
|
||||
tipo: nuevoLote.value.tipo,
|
||||
cantidad_kg: nuevoLote.value.cantidad_kg || undefined,
|
||||
})
|
||||
|
||||
if (!nuevoLoteCreado) {
|
||||
throw new Error('No se pudo crear el lote')
|
||||
}
|
||||
|
||||
loteId = nuevoLoteCreado.id
|
||||
}
|
||||
|
||||
if (!loteId) {
|
||||
throw new Error('Debes seleccionar o crear un lote')
|
||||
}
|
||||
|
||||
// Vincular registros
|
||||
if (props.registros.length === 1) {
|
||||
await vincular(props.tipo, props.registros[0].id, loteId, {
|
||||
observaciones: observaciones.value || undefined,
|
||||
datosCache: props.registros[0],
|
||||
})
|
||||
} else {
|
||||
await vincularMasivo(
|
||||
props.registros.map(r => ({
|
||||
tipo: props.tipo,
|
||||
registroId: r.id,
|
||||
loteId: loteId!,
|
||||
observaciones: observaciones.value || undefined,
|
||||
datosCache: r,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
emit('vinculado')
|
||||
isOpen.value = false
|
||||
} catch (error: any) {
|
||||
console.error('Error vinculando:', error)
|
||||
} finally {
|
||||
guardando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset al abrir
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
loteSeleccionado.value = null
|
||||
mostrarFormNuevoLote.value = false
|
||||
nuevoLote.value = { codigo: '', tipo: '', cantidad_kg: null }
|
||||
observaciones.value = ''
|
||||
cargarLotes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
215
nuxt4/app/components/vinculaciones/ProgressDashboard.vue
Normal file
215
nuxt4/app/components/vinculaciones/ProgressDashboard.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user