Agregar sistema de vinculaciones con registros externos de Metabase
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
@@ -185,6 +185,90 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Tab: Externos - Vinculación de registros externos con lotes -->
|
||||||
|
<template #externos>
|
||||||
|
<div class="py-4">
|
||||||
|
<ClientOnly>
|
||||||
|
<!-- Sub-navegación por tipo de registro -->
|
||||||
|
<UTabs v-model="externosSubTab" :items="externosSubTabs" class="mb-4">
|
||||||
|
<!-- Dashboard de progreso -->
|
||||||
|
<template #dashboard>
|
||||||
|
<VinculacionesProgressDashboard
|
||||||
|
@seleccionar-tipo="handleSeleccionarTipoExterno"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Ingresos -->
|
||||||
|
<template #ingresos>
|
||||||
|
<ExternosTablaRegistros
|
||||||
|
tipo="ingreso"
|
||||||
|
:registros="ingresosData"
|
||||||
|
:loading="ingresosLoading"
|
||||||
|
:meta="ingresosMeta"
|
||||||
|
:columns="ingresosColumns"
|
||||||
|
@refresh="cargarIngresos"
|
||||||
|
@vincular="handleVincularRegistro"
|
||||||
|
@vincular-seleccionados="handleVincularSeleccionados"
|
||||||
|
@ver-detalle="handleVerDetalleRegistro"
|
||||||
|
@update:solo-sin-vincular="(val) => { soloSinVincularIngresos = val; cargarIngresos() }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Carretas -->
|
||||||
|
<template #carretas>
|
||||||
|
<ExternosTablaRegistros
|
||||||
|
tipo="carreta"
|
||||||
|
:registros="carretasData"
|
||||||
|
:loading="carretasLoading"
|
||||||
|
:meta="carretasMeta"
|
||||||
|
:columns="carretasColumns"
|
||||||
|
@refresh="cargarCarretas"
|
||||||
|
@vincular="handleVincularRegistro"
|
||||||
|
@vincular-seleccionados="handleVincularSeleccionados"
|
||||||
|
@ver-detalle="handleVerDetalleRegistro"
|
||||||
|
@update:solo-sin-vincular="(val) => { soloSinVincularCarretas = val; cargarCarretas() }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Salidas -->
|
||||||
|
<template #salidas>
|
||||||
|
<ExternosTablaRegistros
|
||||||
|
tipo="salida"
|
||||||
|
:registros="salidasData"
|
||||||
|
:loading="salidasLoading"
|
||||||
|
:meta="salidasMeta"
|
||||||
|
:columns="salidasColumns"
|
||||||
|
@refresh="cargarSalidas"
|
||||||
|
@vincular="handleVincularRegistro"
|
||||||
|
@vincular-seleccionados="handleVincularSeleccionados"
|
||||||
|
@ver-detalle="handleVerDetalleRegistro"
|
||||||
|
@update:solo-sin-vincular="(val) => { soloSinVincularSalidas = val; cargarSalidas() }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Rechazos -->
|
||||||
|
<template #rechazos>
|
||||||
|
<ExternosTablaRegistros
|
||||||
|
tipo="rechazo"
|
||||||
|
:registros="rechazosData"
|
||||||
|
:loading="rechazosLoading"
|
||||||
|
:meta="rechazosMeta"
|
||||||
|
:columns="rechazosColumns"
|
||||||
|
@refresh="cargarRechazos"
|
||||||
|
@vincular="handleVincularRegistro"
|
||||||
|
@vincular-seleccionados="handleVincularSeleccionados"
|
||||||
|
@ver-detalle="handleVerDetalleRegistro"
|
||||||
|
@update:solo-sin-vincular="(val) => { soloSinVincularRechazos = val; cargarRechazos() }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UTabs>
|
||||||
|
<template #fallback>
|
||||||
|
<USkeleton class="h-64" />
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</UTabs>
|
</UTabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -328,6 +412,14 @@
|
|||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Modal: Asignar Vinculación -->
|
||||||
|
<VinculacionesModalAsignar
|
||||||
|
v-model:open="showVinculacionModal"
|
||||||
|
:tipo="tipoRegistroParaVincular"
|
||||||
|
:registros="registrosParaVincular"
|
||||||
|
@vinculado="handleVinculacionSuccess"
|
||||||
|
/>
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -336,6 +428,9 @@ import type { Lote, Operacion } from '~/composables/useLotes'
|
|||||||
|
|
||||||
const { isAuthenticated } = useAuthentik()
|
const { isAuthenticated } = useAuthentik()
|
||||||
const { fetchLotes: fetchLotesComposable, TIPOS_LOTE, TIPOS_OPERACION } = useLotes()
|
const { fetchLotes: fetchLotesComposable, TIPOS_LOTE, TIPOS_OPERACION } = useLotes()
|
||||||
|
const { fetchIngresos, fetchCarretas, fetchSalidas, fetchRechazos, TIPOS_REGISTRO } = useRegistrosExternos()
|
||||||
|
import type { TipoRegistro } from '~/composables/useRegistrosExternos'
|
||||||
|
import type { ColumnDef } from '@tanstack/vue-table'
|
||||||
|
|
||||||
// Navegación
|
// Navegación
|
||||||
const selectedTab = ref('lotes')
|
const selectedTab = ref('lotes')
|
||||||
@@ -343,6 +438,7 @@ const tabs = [
|
|||||||
{ label: 'Lotes', icon: 'i-heroicons-cube', slot: 'lotes', value: 'lotes' },
|
{ label: 'Lotes', icon: 'i-heroicons-cube', slot: 'lotes', value: 'lotes' },
|
||||||
{ label: 'Operaciones', icon: 'i-heroicons-beaker', slot: 'operaciones', value: 'operaciones' },
|
{ label: 'Operaciones', icon: 'i-heroicons-beaker', slot: 'operaciones', value: 'operaciones' },
|
||||||
{ label: 'Grafos', icon: 'i-heroicons-share', slot: 'grafos', value: 'grafos' },
|
{ label: 'Grafos', icon: 'i-heroicons-share', slot: 'grafos', value: 'grafos' },
|
||||||
|
{ label: 'Externos', icon: 'i-heroicons-link', slot: 'externos', value: 'externos' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Estados de modales
|
// Estados de modales
|
||||||
@@ -445,6 +541,168 @@ const graphSelectItems = computed(() =>
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// SECCIÓN: REGISTROS EXTERNOS Y VINCULACIONES
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
const externosSubTab = ref('dashboard')
|
||||||
|
const externosSubTabs = [
|
||||||
|
{ label: 'Dashboard', icon: 'i-heroicons-chart-pie', slot: 'dashboard', value: 'dashboard' },
|
||||||
|
{ label: 'Ingresos', icon: 'i-heroicons-inbox-arrow-down', slot: 'ingresos', value: 'ingresos' },
|
||||||
|
{ label: 'Carretas', icon: 'i-heroicons-truck', slot: 'carretas', value: 'carretas' },
|
||||||
|
{ label: 'Salidas', icon: 'i-heroicons-arrow-up-tray', slot: 'salidas', value: 'salidas' },
|
||||||
|
{ label: 'Rechazos', icon: 'i-heroicons-x-circle', slot: 'rechazos', value: 'rechazos' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Estados de datos externos
|
||||||
|
const ingresosData = ref<any[]>([])
|
||||||
|
const ingresosLoading = ref(false)
|
||||||
|
const ingresosMeta = ref<any>(null)
|
||||||
|
const soloSinVincularIngresos = ref(false)
|
||||||
|
|
||||||
|
const carretasData = ref<any[]>([])
|
||||||
|
const carretasLoading = ref(false)
|
||||||
|
const carretasMeta = ref<any>(null)
|
||||||
|
const soloSinVincularCarretas = ref(false)
|
||||||
|
|
||||||
|
const salidasData = ref<any[]>([])
|
||||||
|
const salidasLoading = ref(false)
|
||||||
|
const salidasMeta = ref<any>(null)
|
||||||
|
const soloSinVincularSalidas = ref(false)
|
||||||
|
|
||||||
|
const rechazosData = ref<any[]>([])
|
||||||
|
const rechazosLoading = ref(false)
|
||||||
|
const rechazosMeta = ref<any>(null)
|
||||||
|
const soloSinVincularRechazos = ref(false)
|
||||||
|
|
||||||
|
// Modal de vinculación
|
||||||
|
const showVinculacionModal = ref(false)
|
||||||
|
const registrosParaVincular = ref<any[]>([])
|
||||||
|
const tipoRegistroParaVincular = ref<TipoRegistro>('ingreso')
|
||||||
|
|
||||||
|
// Columnas para tablas
|
||||||
|
const ingresosColumns: ColumnDef<any>[] = [
|
||||||
|
{ accessorKey: 'id', header: 'ID' },
|
||||||
|
{ accessorKey: 'created_at', header: 'Fecha' },
|
||||||
|
{ accessorKey: 'tipo', header: 'Tipo' },
|
||||||
|
{ accessorKey: 'cliente_nombre', header: 'Cliente' },
|
||||||
|
{ accessorKey: 'peso_seco', header: 'Peso Seco' },
|
||||||
|
{ accessorKey: 'calidad', header: 'Calidad' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const carretasColumns: ColumnDef<any>[] = [
|
||||||
|
{ accessorKey: 'id', header: 'ID' },
|
||||||
|
{ accessorKey: 'created_at', header: 'Fecha' },
|
||||||
|
{ accessorKey: 'titulo', header: 'Título' },
|
||||||
|
{ accessorKey: 'qq_seco_estimado', header: 'qqSeco Est.' },
|
||||||
|
{ accessorKey: 'estado', header: 'Estado' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const salidasColumns: ColumnDef<any>[] = [
|
||||||
|
{ accessorKey: 'id', header: 'ID' },
|
||||||
|
{ accessorKey: 'created_at', header: 'Fecha' },
|
||||||
|
{ accessorKey: 'comprador', header: 'Comprador' },
|
||||||
|
{ accessorKey: 'qq_seco', header: 'qqSeco' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const rechazosColumns: ColumnDef<any>[] = [
|
||||||
|
{ accessorKey: 'id', header: 'ID' },
|
||||||
|
{ accessorKey: 'created_at', header: 'Fecha' },
|
||||||
|
{ accessorKey: 'tipo', header: 'Tipo' },
|
||||||
|
{ accessorKey: 'cantidad', header: 'Cantidad' },
|
||||||
|
{ accessorKey: 'comprador_nombre', header: 'Comprador' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Funciones para cargar datos externos
|
||||||
|
const cargarIngresos = async () => {
|
||||||
|
ingresosLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchIngresos({ sinVincular: soloSinVincularIngresos.value })
|
||||||
|
ingresosData.value = result.data
|
||||||
|
ingresosMeta.value = result.meta
|
||||||
|
} finally {
|
||||||
|
ingresosLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cargarCarretas = async () => {
|
||||||
|
carretasLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchCarretas({ sinVincular: soloSinVincularCarretas.value })
|
||||||
|
carretasData.value = result.data
|
||||||
|
carretasMeta.value = result.meta
|
||||||
|
} finally {
|
||||||
|
carretasLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cargarSalidas = async () => {
|
||||||
|
salidasLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchSalidas({ sinVincular: soloSinVincularSalidas.value })
|
||||||
|
salidasData.value = result.data
|
||||||
|
salidasMeta.value = result.meta
|
||||||
|
} finally {
|
||||||
|
salidasLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cargarRechazos = async () => {
|
||||||
|
rechazosLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchRechazos({ sinVincular: soloSinVincularRechazos.value })
|
||||||
|
rechazosData.value = result.data
|
||||||
|
rechazosMeta.value = result.meta
|
||||||
|
} finally {
|
||||||
|
rechazosLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers para vinculación
|
||||||
|
const handleVincularRegistro = (registro: any) => {
|
||||||
|
registrosParaVincular.value = [registro]
|
||||||
|
tipoRegistroParaVincular.value = externosSubTab.value as TipoRegistro
|
||||||
|
showVinculacionModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVincularSeleccionados = (registros: any[]) => {
|
||||||
|
registrosParaVincular.value = registros
|
||||||
|
tipoRegistroParaVincular.value = externosSubTab.value as TipoRegistro
|
||||||
|
showVinculacionModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerDetalleRegistro = (registro: any) => {
|
||||||
|
// TODO: Implementar modal de detalle
|
||||||
|
console.log('Ver detalle:', registro)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSeleccionarTipoExterno = (tipo: TipoRegistro) => {
|
||||||
|
const tabMap: Record<TipoRegistro, string> = {
|
||||||
|
ingreso: 'ingresos',
|
||||||
|
carreta: 'carretas',
|
||||||
|
salida: 'salidas',
|
||||||
|
rechazo: 'rechazos',
|
||||||
|
}
|
||||||
|
externosSubTab.value = tabMap[tipo]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVinculacionSuccess = () => {
|
||||||
|
showVinculacionModal.value = false
|
||||||
|
// Recargar el tab actual
|
||||||
|
if (externosSubTab.value === 'ingresos') cargarIngresos()
|
||||||
|
else if (externosSubTab.value === 'carretas') cargarCarretas()
|
||||||
|
else if (externosSubTab.value === 'salidas') cargarSalidas()
|
||||||
|
else if (externosSubTab.value === 'rechazos') cargarRechazos()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar datos cuando cambia el sub-tab
|
||||||
|
watch(externosSubTab, (newTab) => {
|
||||||
|
if (newTab === 'ingresos' && ingresosData.value.length === 0) cargarIngresos()
|
||||||
|
else if (newTab === 'carretas' && carretasData.value.length === 0) cargarCarretas()
|
||||||
|
else if (newTab === 'salidas' && salidasData.value.length === 0) cargarSalidas()
|
||||||
|
else if (newTab === 'rechazos' && rechazosData.value.length === 0) cargarRechazos()
|
||||||
|
})
|
||||||
|
|
||||||
const loadGraphLotes = async () => {
|
const loadGraphLotes = async () => {
|
||||||
graphLoading.value = true
|
graphLoading.value = true
|
||||||
graphError.value = null
|
graphError.value = null
|
||||||
|
|||||||
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>
|
||||||
</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">
|
<div v-if="lote.meta" class="pt-4 border-t">
|
||||||
<p class="text-sm text-gray-500 mb-2">Información Adicional</p>
|
<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">
|
<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">
|
<script setup lang="ts">
|
||||||
import type { Lote } from '~/composables/useLotes'
|
import type { Lote } from '~/composables/useLotes'
|
||||||
|
import type { Vinculacion } from '~/composables/useRegistrosExternos'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
lote: Lote
|
lote: Lote
|
||||||
@@ -78,6 +158,41 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { TIPOS_LOTE } = useLotes()
|
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 getTipoLabel = (tipo: string) => {
|
||||||
const found = TIPOS_LOTE.find((t) => t.value === tipo)
|
const found = TIPOS_LOTE.find((t) => t.value === tipo)
|
||||||
@@ -98,6 +213,16 @@ const getTipoColor = (tipo: string): string => {
|
|||||||
return colorMap[tipo] || 'gray'
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('es-AR', {
|
return new Date(dateString).toLocaleDateString('es-AR', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -107,4 +232,9 @@ const formatDate = (dateString: string) => {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cargar vinculaciones automáticamente al montar
|
||||||
|
onMounted(() => {
|
||||||
|
cargarVinculaciones()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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
@@ -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>
|
||||||
534
nuxt4/app/composables/useRegistrosExternos.ts
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
/**
|
||||||
|
* Composable para gestión de registros externos y vinculaciones
|
||||||
|
*
|
||||||
|
* Registros externos: Ingresos, Carretas, Salidas, Rechazos
|
||||||
|
* Estos registros vienen de Metabase (solo lectura) y se vinculan a lotes locales.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// TIPOS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface Ingreso {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
tipo: string
|
||||||
|
calidad: string
|
||||||
|
humedad: number | null
|
||||||
|
estado: string
|
||||||
|
observacion: string | null
|
||||||
|
sacos_total: number
|
||||||
|
peso_bruto: number
|
||||||
|
peso_neto: number
|
||||||
|
peso_seco: number
|
||||||
|
cliente_id: number | null
|
||||||
|
comercio_id: number | null
|
||||||
|
cliente_nombre: string | null
|
||||||
|
comercio_nombre: string | null
|
||||||
|
vinculado: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Carreta {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
titulo: string
|
||||||
|
estado: string
|
||||||
|
libras_mojadas: number
|
||||||
|
humedad: number
|
||||||
|
qq_seco_estimado: number
|
||||||
|
vinculado: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Salida {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
comprador: string
|
||||||
|
observacion: string | null
|
||||||
|
peso_bruto: number
|
||||||
|
sacos_total: number
|
||||||
|
peso_neto: number
|
||||||
|
qq_seco: number
|
||||||
|
vinculado: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rechazo {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
tipo: string
|
||||||
|
estado: string
|
||||||
|
cantidad: number
|
||||||
|
unidad: string
|
||||||
|
precio_unidad: number | null
|
||||||
|
observacion: string | null
|
||||||
|
comprador_id: number | null
|
||||||
|
comprador_nombre: string | null
|
||||||
|
vinculado: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vinculacion {
|
||||||
|
id: string
|
||||||
|
tipo_registro: 'ingreso' | 'carreta' | 'salida' | 'rechazo'
|
||||||
|
registro_id: number
|
||||||
|
lote_id: string
|
||||||
|
fecha_vinculacion: string
|
||||||
|
usuario_id: string | null
|
||||||
|
observaciones: string | null
|
||||||
|
datos_cache: Record<string, any> | null
|
||||||
|
periodo_cosecha: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstadisticasVinculacion {
|
||||||
|
ingresos: { total: number; vinculados: number; sinVincular: number; porcentaje: number }
|
||||||
|
carretas: { total: number; vinculados: number; sinVincular: number; porcentaje: number }
|
||||||
|
salidas: { total: number; vinculados: number; sinVincular: number; porcentaje: number }
|
||||||
|
rechazos: { total: number; vinculados: number; sinVincular: number; porcentaje: number }
|
||||||
|
resumen: { total: number; vinculados: number; sinVincular: number; porcentaje: number }
|
||||||
|
periodo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TipoRegistro = 'ingreso' | 'carreta' | 'salida' | 'rechazo'
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export const useRegistrosExternos = () => {
|
||||||
|
const toast = process.client ? useToast() : null
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// FETCH DE REGISTROS EXTERNOS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene ingresos del período
|
||||||
|
*/
|
||||||
|
const fetchIngresos = async (opts?: { periodo?: string; sinVincular?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (opts?.periodo) query.append('periodo', opts.periodo)
|
||||||
|
if (opts?.sinVincular) query.append('sinVincular', 'true')
|
||||||
|
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: Ingreso[]
|
||||||
|
meta: { total: number; vinculados: number; sinVincular: number }
|
||||||
|
}>(`/api/externos/ingresos?${query.toString()}`)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error obteniendo ingresos')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.value?.data || [],
|
||||||
|
meta: data.value?.meta,
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching ingresos:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error obteniendo ingresos',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return { data: [], meta: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene carretas del período
|
||||||
|
*/
|
||||||
|
const fetchCarretas = async (opts?: { periodo?: string; sinVincular?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (opts?.periodo) query.append('periodo', opts.periodo)
|
||||||
|
if (opts?.sinVincular) query.append('sinVincular', 'true')
|
||||||
|
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: Carreta[]
|
||||||
|
meta: { total: number; vinculados: number; sinVincular: number }
|
||||||
|
}>(`/api/externos/carretas?${query.toString()}`)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error obteniendo carretas')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.value?.data || [],
|
||||||
|
meta: data.value?.meta,
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching carretas:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error obteniendo carretas',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return { data: [], meta: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene salidas del período
|
||||||
|
*/
|
||||||
|
const fetchSalidas = async (opts?: { periodo?: string; sinVincular?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (opts?.periodo) query.append('periodo', opts.periodo)
|
||||||
|
if (opts?.sinVincular) query.append('sinVincular', 'true')
|
||||||
|
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: Salida[]
|
||||||
|
meta: { total: number; vinculados: number; sinVincular: number }
|
||||||
|
}>(`/api/externos/salidas?${query.toString()}`)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error obteniendo salidas')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.value?.data || [],
|
||||||
|
meta: data.value?.meta,
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching salidas:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error obteniendo salidas',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return { data: [], meta: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene rechazos del período
|
||||||
|
*/
|
||||||
|
const fetchRechazos = async (opts?: { periodo?: string; sinVincular?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (opts?.periodo) query.append('periodo', opts.periodo)
|
||||||
|
if (opts?.sinVincular) query.append('sinVincular', 'true')
|
||||||
|
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: Rechazo[]
|
||||||
|
meta: { total: number; vinculados: number; sinVincular: number }
|
||||||
|
}>(`/api/externos/rechazos?${query.toString()}`)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error obteniendo rechazos')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.value?.data || [],
|
||||||
|
meta: data.value?.meta,
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching rechazos:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error obteniendo rechazos',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return { data: [], meta: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadísticas de vinculación
|
||||||
|
*/
|
||||||
|
const fetchEstadisticas = async (periodo?: string) => {
|
||||||
|
try {
|
||||||
|
const query = periodo ? `?periodo=${periodo}` : ''
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: EstadisticasVinculacion
|
||||||
|
}>(`/api/externos/stats${query}`)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error obteniendo estadísticas')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.value?.data || null
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching estadisticas:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error obteniendo estadísticas',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// GESTIÓN DE VINCULACIONES
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincula un registro externo a un lote
|
||||||
|
*/
|
||||||
|
const vincular = async (
|
||||||
|
tipo: TipoRegistro,
|
||||||
|
registroId: number,
|
||||||
|
loteId: string,
|
||||||
|
opts?: { observaciones?: string; datosCache?: Record<string, any> }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: Vinculacion
|
||||||
|
message: string
|
||||||
|
}>('/api/vinculaciones', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
tipo_registro: tipo,
|
||||||
|
registro_id: registroId,
|
||||||
|
lote_id: loteId,
|
||||||
|
observaciones: opts?.observaciones,
|
||||||
|
datos_cache: opts?.datosCache,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error creando vinculación')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast?.add({
|
||||||
|
title: 'Vinculado',
|
||||||
|
description: 'Registro vinculado correctamente al lote',
|
||||||
|
color: 'green',
|
||||||
|
})
|
||||||
|
|
||||||
|
return data.value?.data || null
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error vinculando:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error creando vinculación',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincula múltiples registros a un lote
|
||||||
|
*/
|
||||||
|
const vincularMasivo = async (
|
||||||
|
vinculaciones: Array<{
|
||||||
|
tipo: TipoRegistro
|
||||||
|
registroId: number
|
||||||
|
loteId: string
|
||||||
|
observaciones?: string
|
||||||
|
datosCache?: Record<string, any>
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: Vinculacion[]
|
||||||
|
message: string
|
||||||
|
}>('/api/vinculaciones', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
masivo: true,
|
||||||
|
items: vinculaciones.map((v) => ({
|
||||||
|
tipo_registro: v.tipo,
|
||||||
|
registro_id: v.registroId,
|
||||||
|
lote_id: v.loteId,
|
||||||
|
observaciones: v.observaciones,
|
||||||
|
datos_cache: v.datosCache,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error creando vinculaciones')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast?.add({
|
||||||
|
title: 'Vinculados',
|
||||||
|
description: `${data.value?.data?.length || 0} registros vinculados correctamente`,
|
||||||
|
color: 'green',
|
||||||
|
})
|
||||||
|
|
||||||
|
return data.value?.data || []
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error vinculando masivo:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error creando vinculaciones',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una vinculación
|
||||||
|
*/
|
||||||
|
const desvincular = async (vinculacionId: string) => {
|
||||||
|
try {
|
||||||
|
const { error } = await useFetch(`/api/vinculaciones/${vinculacionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error eliminando vinculación')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast?.add({
|
||||||
|
title: 'Desvinculado',
|
||||||
|
description: 'Vinculación eliminada correctamente',
|
||||||
|
color: 'green',
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error desvinculando:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error eliminando vinculación',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene vinculaciones de un lote
|
||||||
|
*/
|
||||||
|
const fetchVinculacionesByLote = async (loteId: string) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await useFetch<{
|
||||||
|
success: boolean
|
||||||
|
data: Vinculacion[]
|
||||||
|
agrupados: {
|
||||||
|
ingresos: Vinculacion[]
|
||||||
|
carretas: Vinculacion[]
|
||||||
|
salidas: Vinculacion[]
|
||||||
|
rechazos: Vinculacion[]
|
||||||
|
}
|
||||||
|
meta: {
|
||||||
|
lote_id: string
|
||||||
|
total: number
|
||||||
|
por_tipo: {
|
||||||
|
ingresos: number
|
||||||
|
carretas: number
|
||||||
|
salidas: number
|
||||||
|
rechazos: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>(`/api/vinculaciones/por-lote/${loteId}`)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw new Error(error.value.message || 'Error obteniendo vinculaciones')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.value?.data || [],
|
||||||
|
agrupados: data.value?.agrupados,
|
||||||
|
meta: data.value?.meta,
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error fetching vinculaciones:', err)
|
||||||
|
toast?.add({
|
||||||
|
title: 'Error',
|
||||||
|
description: err.message || 'Error obteniendo vinculaciones',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
return { data: [], agrupados: null, meta: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// CONSTANTES
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
const TIPOS_REGISTRO = [
|
||||||
|
{ value: 'ingreso', label: 'Ingresos', labelSingular: 'Ingreso', icon: 'i-heroicons-inbox-arrow-down', color: 'purple' },
|
||||||
|
{ value: 'carreta', label: 'Carretas', labelSingular: 'Carreta', icon: 'i-heroicons-truck', color: 'blue' },
|
||||||
|
{ value: 'salida', label: 'Salidas', labelSingular: 'Salida', icon: 'i-heroicons-arrow-up-tray', color: 'green' },
|
||||||
|
{ value: 'rechazo', label: 'Rechazos', labelSingular: 'Rechazo', icon: 'i-heroicons-x-circle', color: 'red' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const PERIODOS_COSECHA = [
|
||||||
|
{ value: '25-26', label: 'Cosecha 2025-2026 (Sep 2025 - Sep 2026)' },
|
||||||
|
{ value: '24-25', label: 'Cosecha 2024-2025 (Sep 2024 - Sep 2025)' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// HELPERS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el label de un tipo de registro
|
||||||
|
*/
|
||||||
|
const getTipoLabel = (tipo: TipoRegistro, plural = true) => {
|
||||||
|
const found = TIPOS_REGISTRO.find((t) => t.value === tipo)
|
||||||
|
return plural ? found?.label || tipo : found?.labelSingular || tipo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el icono de un tipo de registro
|
||||||
|
*/
|
||||||
|
const getTipoIcon = (tipo: TipoRegistro) => {
|
||||||
|
const found = TIPOS_REGISTRO.find((t) => t.value === tipo)
|
||||||
|
return found?.icon || 'i-heroicons-document'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el color de un tipo de registro
|
||||||
|
*/
|
||||||
|
const getTipoColor = (tipo: TipoRegistro) => {
|
||||||
|
const found = TIPOS_REGISTRO.find((t) => t.value === tipo)
|
||||||
|
return found?.color || 'gray'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha para mostrar
|
||||||
|
*/
|
||||||
|
const formatFecha = (fecha: string) => {
|
||||||
|
return new Date(fecha).toLocaleDateString('es-HN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea un número como peso
|
||||||
|
*/
|
||||||
|
const formatPeso = (peso: number | null, unidad = 'qq') => {
|
||||||
|
if (peso === null || peso === undefined) return '-'
|
||||||
|
return `${peso.toFixed(2)} ${unidad}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Fetch registros externos
|
||||||
|
fetchIngresos,
|
||||||
|
fetchCarretas,
|
||||||
|
fetchSalidas,
|
||||||
|
fetchRechazos,
|
||||||
|
fetchEstadisticas,
|
||||||
|
|
||||||
|
// Vinculaciones
|
||||||
|
vincular,
|
||||||
|
vincularMasivo,
|
||||||
|
desvincular,
|
||||||
|
fetchVinculacionesByLote,
|
||||||
|
|
||||||
|
// Constantes
|
||||||
|
TIPOS_REGISTRO,
|
||||||
|
PERIODOS_COSECHA,
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
getTipoLabel,
|
||||||
|
getTipoIcon,
|
||||||
|
getTipoColor,
|
||||||
|
formatFecha,
|
||||||
|
formatPeso,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
postgresUrl: process.env.NUXT_POSTGRES_URL || '',
|
postgresUrl: process.env.NUXT_POSTGRES_URL || '',
|
||||||
|
// Metabase API para consultar registros externos
|
||||||
|
metabaseUrl: process.env.NUXT_METABASE_URL || 'http://metabase:3000',
|
||||||
|
metabaseApiKey: process.env.NUXT_METABASE_API_KEY || '',
|
||||||
|
metabaseDatabaseId: parseInt(process.env.NUXT_METABASE_DATABASE_ID || '2'),
|
||||||
public: {
|
public: {
|
||||||
authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com'
|
authentikUrl: process.env.NUXT_PUBLIC_AUTHENTIK_URL || 'https://authentik.nucleoriofrio.com'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
await client.query('BEGIN')
|
await client.query('BEGIN')
|
||||||
|
|
||||||
// Eliminar completamente las tablas (DROP) para que el workflow las recree
|
// Eliminar completamente las tablas (DROP) para que el workflow las recree
|
||||||
|
await client.query('DROP TABLE IF EXISTS vinculaciones_externas CASCADE')
|
||||||
await client.query('DROP TABLE IF EXISTS operacion_lotes CASCADE')
|
await client.query('DROP TABLE IF EXISTS operacion_lotes CASCADE')
|
||||||
await client.query('DROP TABLE IF EXISTS operaciones CASCADE')
|
await client.query('DROP TABLE IF EXISTS operaciones CASCADE')
|
||||||
await client.query('DROP TABLE IF EXISTS lotes CASCADE')
|
await client.query('DROP TABLE IF EXISTS lotes CASCADE')
|
||||||
|
|
||||||
// También eliminar la función y vista si existen
|
// También eliminar las funciones y vistas si existen
|
||||||
await client.query('DROP FUNCTION IF EXISTS get_trazabilidad CASCADE')
|
await client.query('DROP FUNCTION IF EXISTS get_trazabilidad CASCADE')
|
||||||
|
await client.query('DROP FUNCTION IF EXISTS get_estadisticas_vinculacion CASCADE')
|
||||||
await client.query('DROP VIEW IF EXISTS vista_lotes_con_origen CASCADE')
|
await client.query('DROP VIEW IF EXISTS vista_lotes_con_origen CASCADE')
|
||||||
|
await client.query('DROP VIEW IF EXISTS vista_lotes_con_vinculaciones CASCADE')
|
||||||
|
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Leer los archivos SQL
|
// Leer los archivos SQL
|
||||||
const schemaPath = join(process.cwd(), 'server', 'database', '01_schema.sql')
|
const schemaPath = join(process.cwd(), 'server', 'database', '01_schema.sql')
|
||||||
const seedPath = join(process.cwd(), 'server', 'database', '02_seed.sql')
|
const seedPath = join(process.cwd(), 'server', 'database', '02_seed.sql')
|
||||||
|
const vinculacionesPath = join(process.cwd(), 'server', 'database', '03_vinculaciones_externas.sql')
|
||||||
|
|
||||||
const schemaSQL = await readFile(schemaPath, 'utf-8')
|
const schemaSQL = await readFile(schemaPath, 'utf-8')
|
||||||
const seedSQL = await readFile(seedPath, 'utf-8')
|
const seedSQL = await readFile(seedPath, 'utf-8')
|
||||||
|
const vinculacionesSQL = await readFile(vinculacionesPath, 'utf-8')
|
||||||
|
|
||||||
await client.query('BEGIN')
|
await client.query('BEGIN')
|
||||||
|
|
||||||
@@ -46,12 +48,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Si no existen las tablas, ejecutar schema completo
|
// Si no existen las tablas, ejecutar schema completo
|
||||||
console.log('📋 Las tablas no existen. Creando schema...')
|
console.log('📋 Las tablas no existen. Creando schema...')
|
||||||
await client.query(schemaSQL)
|
await client.query(schemaSQL)
|
||||||
|
console.log('📋 Creando schema de vinculaciones externas...')
|
||||||
|
await client.query(vinculacionesSQL)
|
||||||
} else {
|
} else {
|
||||||
console.log('📋 Las tablas ya existen. Limpiando datos existentes...')
|
console.log('📋 Las tablas ya existen. Limpiando datos existentes...')
|
||||||
// Si las tablas existen, limpiar datos antes de cargar seed
|
// Si las tablas existen, limpiar datos antes de cargar seed
|
||||||
|
await client.query('TRUNCATE TABLE vinculaciones_externas CASCADE')
|
||||||
await client.query('TRUNCATE TABLE operacion_lotes CASCADE')
|
await client.query('TRUNCATE TABLE operacion_lotes CASCADE')
|
||||||
await client.query('TRUNCATE TABLE operaciones CASCADE')
|
await client.query('TRUNCATE TABLE operaciones CASCADE')
|
||||||
await client.query('TRUNCATE TABLE lotes CASCADE')
|
await client.query('TRUNCATE TABLE lotes CASCADE')
|
||||||
|
// Asegurar que el schema de vinculaciones esté actualizado
|
||||||
|
console.log('📋 Actualizando schema de vinculaciones externas...')
|
||||||
|
await client.query(vinculacionesSQL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ejecutar seed (inserta datos de ejemplo)
|
// Ejecutar seed (inserta datos de ejemplo)
|
||||||
|
|||||||
52
nuxt4/server/api/externos/carretas.get.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/externos/carretas
|
||||||
|
*
|
||||||
|
* Obtiene las carretas del período de cosecha desde Metabase.
|
||||||
|
* Incluye información de estado de vinculación.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCarretas } from '../../utils/metabase'
|
||||||
|
import { getRegistrosVinculados } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const periodo = (query.periodo as string) || '25-26'
|
||||||
|
const soloSinVincular = query.sinVincular === 'true'
|
||||||
|
|
||||||
|
// Obtener carretas desde Metabase
|
||||||
|
const carretas = await getCarretas(periodo)
|
||||||
|
|
||||||
|
// Obtener IDs ya vinculados desde nuestra BD local
|
||||||
|
const vinculados = await getRegistrosVinculados('carreta', periodo)
|
||||||
|
const vinculadosSet = new Set(vinculados)
|
||||||
|
|
||||||
|
// Marcar cada carreta con su estado de vinculación
|
||||||
|
const carretasConEstado = carretas.map((carreta: any) => ({
|
||||||
|
...carreta,
|
||||||
|
vinculado: vinculadosSet.has(carreta.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Filtrar si solo se quieren los no vinculados
|
||||||
|
const resultado = soloSinVincular
|
||||||
|
? carretasConEstado.filter((c: any) => !c.vinculado)
|
||||||
|
: carretasConEstado
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: resultado,
|
||||||
|
meta: {
|
||||||
|
total: resultado.length,
|
||||||
|
vinculados: carretasConEstado.filter((c: any) => c.vinculado).length,
|
||||||
|
sinVincular: carretasConEstado.filter((c: any) => !c.vinculado).length,
|
||||||
|
periodo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error obteniendo carretas:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.message || 'Error obteniendo carretas',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
52
nuxt4/server/api/externos/ingresos.get.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/externos/ingresos
|
||||||
|
*
|
||||||
|
* Obtiene los ingresos del período de cosecha desde Metabase.
|
||||||
|
* Incluye información de estado de vinculación.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIngresos } from '../../utils/metabase'
|
||||||
|
import { getRegistrosVinculados } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const periodo = (query.periodo as string) || '25-26'
|
||||||
|
const soloSinVincular = query.sinVincular === 'true'
|
||||||
|
|
||||||
|
// Obtener ingresos desde Metabase
|
||||||
|
const ingresos = await getIngresos(periodo)
|
||||||
|
|
||||||
|
// Obtener IDs ya vinculados desde nuestra BD local
|
||||||
|
const vinculados = await getRegistrosVinculados('ingreso', periodo)
|
||||||
|
const vinculadosSet = new Set(vinculados)
|
||||||
|
|
||||||
|
// Marcar cada ingreso con su estado de vinculación
|
||||||
|
const ingresosConEstado = ingresos.map((ingreso: any) => ({
|
||||||
|
...ingreso,
|
||||||
|
vinculado: vinculadosSet.has(ingreso.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Filtrar si solo se quieren los no vinculados
|
||||||
|
const resultado = soloSinVincular
|
||||||
|
? ingresosConEstado.filter((i: any) => !i.vinculado)
|
||||||
|
: ingresosConEstado
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: resultado,
|
||||||
|
meta: {
|
||||||
|
total: resultado.length,
|
||||||
|
vinculados: ingresosConEstado.filter((i: any) => i.vinculado).length,
|
||||||
|
sinVincular: ingresosConEstado.filter((i: any) => !i.vinculado).length,
|
||||||
|
periodo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error obteniendo ingresos:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.message || 'Error obteniendo ingresos',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
52
nuxt4/server/api/externos/rechazos.get.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/externos/rechazos
|
||||||
|
*
|
||||||
|
* Obtiene los rechazos del período de cosecha desde Metabase.
|
||||||
|
* Incluye información de estado de vinculación.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getRechazos } from '../../utils/metabase'
|
||||||
|
import { getRegistrosVinculados } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const periodo = (query.periodo as string) || '25-26'
|
||||||
|
const soloSinVincular = query.sinVincular === 'true'
|
||||||
|
|
||||||
|
// Obtener rechazos desde Metabase
|
||||||
|
const rechazos = await getRechazos(periodo)
|
||||||
|
|
||||||
|
// Obtener IDs ya vinculados desde nuestra BD local
|
||||||
|
const vinculados = await getRegistrosVinculados('rechazo', periodo)
|
||||||
|
const vinculadosSet = new Set(vinculados)
|
||||||
|
|
||||||
|
// Marcar cada rechazo con su estado de vinculación
|
||||||
|
const rechazosConEstado = rechazos.map((rechazo: any) => ({
|
||||||
|
...rechazo,
|
||||||
|
vinculado: vinculadosSet.has(rechazo.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Filtrar si solo se quieren los no vinculados
|
||||||
|
const resultado = soloSinVincular
|
||||||
|
? rechazosConEstado.filter((r: any) => !r.vinculado)
|
||||||
|
: rechazosConEstado
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: resultado,
|
||||||
|
meta: {
|
||||||
|
total: resultado.length,
|
||||||
|
vinculados: rechazosConEstado.filter((r: any) => r.vinculado).length,
|
||||||
|
sinVincular: rechazosConEstado.filter((r: any) => !r.vinculado).length,
|
||||||
|
periodo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error obteniendo rechazos:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.message || 'Error obteniendo rechazos',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
52
nuxt4/server/api/externos/salidas.get.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/externos/salidas
|
||||||
|
*
|
||||||
|
* Obtiene las salidas del período de cosecha desde Metabase.
|
||||||
|
* Incluye información de estado de vinculación.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSalidas } from '../../utils/metabase'
|
||||||
|
import { getRegistrosVinculados } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const periodo = (query.periodo as string) || '25-26'
|
||||||
|
const soloSinVincular = query.sinVincular === 'true'
|
||||||
|
|
||||||
|
// Obtener salidas desde Metabase
|
||||||
|
const salidas = await getSalidas(periodo)
|
||||||
|
|
||||||
|
// Obtener IDs ya vinculados desde nuestra BD local
|
||||||
|
const vinculados = await getRegistrosVinculados('salida', periodo)
|
||||||
|
const vinculadosSet = new Set(vinculados)
|
||||||
|
|
||||||
|
// Marcar cada salida con su estado de vinculación
|
||||||
|
const salidasConEstado = salidas.map((salida: any) => ({
|
||||||
|
...salida,
|
||||||
|
vinculado: vinculadosSet.has(salida.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Filtrar si solo se quieren los no vinculados
|
||||||
|
const resultado = soloSinVincular
|
||||||
|
? salidasConEstado.filter((s: any) => !s.vinculado)
|
||||||
|
: salidasConEstado
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: resultado,
|
||||||
|
meta: {
|
||||||
|
total: resultado.length,
|
||||||
|
vinculados: salidasConEstado.filter((s: any) => s.vinculado).length,
|
||||||
|
sinVincular: salidasConEstado.filter((s: any) => !s.vinculado).length,
|
||||||
|
periodo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error obteniendo salidas:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.message || 'Error obteniendo salidas',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
81
nuxt4/server/api/externos/stats.get.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/externos/stats
|
||||||
|
*
|
||||||
|
* Obtiene estadísticas de vinculación para el dashboard.
|
||||||
|
* Combina datos de Metabase (totales) con nuestra BD local (vinculados).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConteoRegistros } from '../../utils/metabase'
|
||||||
|
import { getEstadisticasVinculacion } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const periodo = (query.periodo as string) || '25-26'
|
||||||
|
|
||||||
|
// Obtener conteos totales desde Metabase
|
||||||
|
const totales = await getConteoRegistros(periodo)
|
||||||
|
|
||||||
|
// Obtener conteos de vinculados desde nuestra BD local
|
||||||
|
const vinculados = await getEstadisticasVinculacion(periodo)
|
||||||
|
|
||||||
|
// Calcular estadísticas completas
|
||||||
|
const stats = {
|
||||||
|
ingresos: {
|
||||||
|
total: totales.ingresos,
|
||||||
|
vinculados: vinculados.ingreso.vinculados,
|
||||||
|
sinVincular: totales.ingresos - vinculados.ingreso.vinculados,
|
||||||
|
porcentaje: totales.ingresos > 0
|
||||||
|
? Math.round((vinculados.ingreso.vinculados / totales.ingresos) * 100)
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
carretas: {
|
||||||
|
total: totales.carretas,
|
||||||
|
vinculados: vinculados.carreta.vinculados,
|
||||||
|
sinVincular: totales.carretas - vinculados.carreta.vinculados,
|
||||||
|
porcentaje: totales.carretas > 0
|
||||||
|
? Math.round((vinculados.carreta.vinculados / totales.carretas) * 100)
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
salidas: {
|
||||||
|
total: totales.salidas,
|
||||||
|
vinculados: vinculados.salida.vinculados,
|
||||||
|
sinVincular: totales.salidas - vinculados.salida.vinculados,
|
||||||
|
porcentaje: totales.salidas > 0
|
||||||
|
? Math.round((vinculados.salida.vinculados / totales.salidas) * 100)
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
rechazos: {
|
||||||
|
total: totales.rechazos,
|
||||||
|
vinculados: vinculados.rechazo.vinculados,
|
||||||
|
sinVincular: totales.rechazos - vinculados.rechazo.vinculados,
|
||||||
|
porcentaje: totales.rechazos > 0
|
||||||
|
? Math.round((vinculados.rechazo.vinculados / totales.rechazos) * 100)
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
resumen: {
|
||||||
|
total: totales.ingresos + totales.carretas + totales.salidas + totales.rechazos,
|
||||||
|
vinculados: vinculados.total_vinculados,
|
||||||
|
sinVincular: (totales.ingresos + totales.carretas + totales.salidas + totales.rechazos) - vinculados.total_vinculados,
|
||||||
|
porcentaje: 0,
|
||||||
|
},
|
||||||
|
periodo,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular porcentaje general
|
||||||
|
stats.resumen.porcentaje = stats.resumen.total > 0
|
||||||
|
? Math.round((stats.resumen.vinculados / stats.resumen.total) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error obteniendo estadísticas:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.message || 'Error obteniendo estadísticas',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
40
nuxt4/server/api/vinculaciones/[id].delete.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* DELETE /api/vinculaciones/:id
|
||||||
|
*
|
||||||
|
* Elimina una vinculación por su ID.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deleteVinculacion } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'ID requerido',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const eliminado = await deleteVinculacion(id)
|
||||||
|
|
||||||
|
if (!eliminado) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Vinculación no encontrada',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Vinculación eliminada exitosamente',
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error eliminando vinculación:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || error.message || 'Error eliminando vinculación',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
38
nuxt4/server/api/vinculaciones/index.get.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/vinculaciones
|
||||||
|
*
|
||||||
|
* Lista vinculaciones con filtros opcionales.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getVinculaciones } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
|
const filtros = {
|
||||||
|
tipo_registro: query.tipo as string | undefined,
|
||||||
|
lote_id: query.lote_id as string | undefined,
|
||||||
|
periodo_cosecha: (query.periodo as string) || '25-26',
|
||||||
|
limit: query.limit ? parseInt(query.limit as string) : undefined,
|
||||||
|
offset: query.offset ? parseInt(query.offset as string) : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const vinculaciones = await getVinculaciones(filtros)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: vinculaciones,
|
||||||
|
meta: {
|
||||||
|
total: vinculaciones.length,
|
||||||
|
filtros,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error obteniendo vinculaciones:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Error obteniendo vinculaciones',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
131
nuxt4/server/api/vinculaciones/index.post.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/vinculaciones
|
||||||
|
*
|
||||||
|
* Crea una o múltiples vinculaciones.
|
||||||
|
*
|
||||||
|
* Body para vinculación individual:
|
||||||
|
* {
|
||||||
|
* tipo_registro: 'ingreso' | 'carreta' | 'salida' | 'rechazo',
|
||||||
|
* registro_id: number,
|
||||||
|
* lote_id: string,
|
||||||
|
* observaciones?: string,
|
||||||
|
* datos_cache?: object
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Body para vinculación masiva:
|
||||||
|
* {
|
||||||
|
* masivo: true,
|
||||||
|
* items: Array<{
|
||||||
|
* tipo_registro: string,
|
||||||
|
* registro_id: number,
|
||||||
|
* lote_id: string,
|
||||||
|
* observaciones?: string,
|
||||||
|
* datos_cache?: object
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createVinculacion, createVinculacionesMasivas } from '../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
// Obtener usuario de Authentik si está disponible
|
||||||
|
const headers = getHeaders(event)
|
||||||
|
const usuarioId = headers['x-authentik-username'] || headers['x-authentik-uid'] || null
|
||||||
|
|
||||||
|
// Validar body
|
||||||
|
if (!body) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Body requerido',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vinculación masiva
|
||||||
|
if (body.masivo && Array.isArray(body.items)) {
|
||||||
|
if (body.items.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Se requiere al menos un item para vinculación masiva',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar cada item
|
||||||
|
for (const item of body.items) {
|
||||||
|
if (!item.tipo_registro || !item.registro_id || !item.lote_id) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Cada item requiere tipo_registro, registro_id y lote_id',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vinculaciones = await createVinculacionesMasivas(
|
||||||
|
body.items.map((item: any) => ({
|
||||||
|
tipo_registro: item.tipo_registro,
|
||||||
|
registro_id: item.registro_id,
|
||||||
|
lote_id: item.lote_id,
|
||||||
|
usuario_id: usuarioId,
|
||||||
|
observaciones: item.observaciones,
|
||||||
|
datos_cache: item.datos_cache,
|
||||||
|
periodo_cosecha: item.periodo_cosecha || '25-26',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: vinculaciones,
|
||||||
|
message: `${vinculaciones.length} vinculaciones creadas exitosamente`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vinculación individual
|
||||||
|
if (!body.tipo_registro || !body.registro_id || !body.lote_id) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Se requiere tipo_registro, registro_id y lote_id',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const vinculacion = await createVinculacion({
|
||||||
|
tipo_registro: body.tipo_registro,
|
||||||
|
registro_id: body.registro_id,
|
||||||
|
lote_id: body.lote_id,
|
||||||
|
usuario_id: usuarioId,
|
||||||
|
observaciones: body.observaciones,
|
||||||
|
datos_cache: body.datos_cache,
|
||||||
|
periodo_cosecha: body.periodo_cosecha || '25-26',
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: vinculacion,
|
||||||
|
message: 'Vinculación creada exitosamente',
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error creando vinculación:', error)
|
||||||
|
|
||||||
|
// Error de constraint único (registro ya vinculado)
|
||||||
|
if (error.code === '23505') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Este registro ya está vinculado a un lote en este período',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error de FK (lote no existe)
|
||||||
|
if (error.code === '23503') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'El lote especificado no existe',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || error.message || 'Error creando vinculación',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
52
nuxt4/server/api/vinculaciones/por-lote/[loteId].get.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/vinculaciones/por-lote/:loteId
|
||||||
|
*
|
||||||
|
* Obtiene todas las vinculaciones de un lote específico.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getVinculacionesByLote } from '../../../utils/queries'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const loteId = getRouterParam(event, 'loteId')
|
||||||
|
|
||||||
|
if (!loteId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'loteId requerido',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const vinculaciones = await getVinculacionesByLote(loteId)
|
||||||
|
|
||||||
|
// Agrupar por tipo de registro
|
||||||
|
const agrupados = {
|
||||||
|
ingresos: vinculaciones.filter(v => v.tipo_registro === 'ingreso'),
|
||||||
|
carretas: vinculaciones.filter(v => v.tipo_registro === 'carreta'),
|
||||||
|
salidas: vinculaciones.filter(v => v.tipo_registro === 'salida'),
|
||||||
|
rechazos: vinculaciones.filter(v => v.tipo_registro === 'rechazo'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: vinculaciones,
|
||||||
|
agrupados,
|
||||||
|
meta: {
|
||||||
|
lote_id: loteId,
|
||||||
|
total: vinculaciones.length,
|
||||||
|
por_tipo: {
|
||||||
|
ingresos: agrupados.ingresos.length,
|
||||||
|
carretas: agrupados.carretas.length,
|
||||||
|
salidas: agrupados.salidas.length,
|
||||||
|
rechazos: agrupados.rechazos.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[API] Error obteniendo vinculaciones del lote:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: error.message || 'Error obteniendo vinculaciones del lote',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
128
nuxt4/server/database/03_vinculaciones_externas.sql
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- SISTEMA DE TRAZABILIDAD - VINCULACIONES EXTERNAS
|
||||||
|
-- =====================================================
|
||||||
|
-- Este esquema permite vincular registros de sistemas externos
|
||||||
|
-- (Ingresos, Carretas, Salidas, Rechazos) con los lotes del sistema.
|
||||||
|
-- Los registros externos se consultan via Metabase API (solo lectura).
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- TABLA: vinculaciones_externas
|
||||||
|
-- =====================================================
|
||||||
|
-- Relaciona registros de tablas externas con lotes locales.
|
||||||
|
-- Cardinalidad: N registros → 1 lote
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vinculaciones_externas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|
||||||
|
-- Identificación del registro externo
|
||||||
|
tipo_registro TEXT NOT NULL, -- 'ingreso', 'carreta', 'salida', 'rechazo'
|
||||||
|
registro_id BIGINT NOT NULL, -- ID del registro en la tabla externa
|
||||||
|
|
||||||
|
-- Vinculación al lote local
|
||||||
|
lote_id UUID NOT NULL REFERENCES lotes(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Metadatos de la vinculación
|
||||||
|
fecha_vinculacion TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
usuario_id TEXT, -- Usuario de Authentik que hizo la vinculación
|
||||||
|
observaciones TEXT,
|
||||||
|
|
||||||
|
-- Cache de datos del registro externo (para evitar consultas repetidas)
|
||||||
|
datos_cache JSONB, -- Snapshot del registro al momento de vincular
|
||||||
|
|
||||||
|
-- Periodo de cosecha (para filtros y unicidad)
|
||||||
|
periodo_cosecha TEXT NOT NULL DEFAULT '25-26', -- '25-26' para cosecha 2025-2026
|
||||||
|
|
||||||
|
-- Un registro externo solo puede vincularse una vez por periodo
|
||||||
|
CONSTRAINT vinculaciones_registro_unico UNIQUE (tipo_registro, registro_id, periodo_cosecha),
|
||||||
|
|
||||||
|
CONSTRAINT vinculaciones_tipo_valido CHECK (tipo_registro IN (
|
||||||
|
'ingreso',
|
||||||
|
'carreta',
|
||||||
|
'salida',
|
||||||
|
'rechazo'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Índices para consultas frecuentes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vinculaciones_lote ON vinculaciones_externas(lote_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vinculaciones_tipo ON vinculaciones_externas(tipo_registro);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vinculaciones_periodo ON vinculaciones_externas(periodo_cosecha);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vinculaciones_registro ON vinculaciones_externas(tipo_registro, registro_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vinculaciones_fecha ON vinculaciones_externas(fecha_vinculacion DESC);
|
||||||
|
|
||||||
|
-- Comentarios
|
||||||
|
COMMENT ON TABLE vinculaciones_externas IS 'Vincula registros de sistemas externos (Ingresos, Carretas, Salidas, Rechazos) con lotes locales';
|
||||||
|
COMMENT ON COLUMN vinculaciones_externas.tipo_registro IS 'Tipo de registro externo: ingreso, carreta, salida, rechazo';
|
||||||
|
COMMENT ON COLUMN vinculaciones_externas.registro_id IS 'ID del registro en la tabla externa (Metabase)';
|
||||||
|
COMMENT ON COLUMN vinculaciones_externas.lote_id IS 'FK al lote local al que se vincula';
|
||||||
|
COMMENT ON COLUMN vinculaciones_externas.datos_cache IS 'Snapshot JSONB del registro al momento de vincular (para evitar consultas repetidas)';
|
||||||
|
COMMENT ON COLUMN vinculaciones_externas.periodo_cosecha IS 'Periodo de cosecha en formato YY-YY (ej: 25-26)';
|
||||||
|
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- VISTA: vista_lotes_con_vinculaciones
|
||||||
|
-- =====================================================
|
||||||
|
-- Muestra cada lote con el conteo de vinculaciones por tipo.
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vista_lotes_con_vinculaciones AS
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.codigo,
|
||||||
|
l.tipo,
|
||||||
|
l.fecha_creado,
|
||||||
|
l.cantidad_kg,
|
||||||
|
l.meta,
|
||||||
|
l.lugar_id,
|
||||||
|
COUNT(*) FILTER (WHERE v.tipo_registro = 'ingreso') AS ingresos_vinculados,
|
||||||
|
COUNT(*) FILTER (WHERE v.tipo_registro = 'carreta') AS carretas_vinculadas,
|
||||||
|
COUNT(*) FILTER (WHERE v.tipo_registro = 'salida') AS salidas_vinculadas,
|
||||||
|
COUNT(*) FILTER (WHERE v.tipo_registro = 'rechazo') AS rechazos_vinculados,
|
||||||
|
COUNT(v.id) AS total_vinculaciones
|
||||||
|
FROM lotes l
|
||||||
|
LEFT JOIN vinculaciones_externas v ON v.lote_id = l.id
|
||||||
|
GROUP BY l.id, l.codigo, l.tipo, l.fecha_creado, l.cantidad_kg, l.meta, l.lugar_id;
|
||||||
|
|
||||||
|
COMMENT ON VIEW vista_lotes_con_vinculaciones IS 'Muestra lotes con conteo de registros vinculados por tipo';
|
||||||
|
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- FUNCIÓN: get_estadisticas_vinculacion
|
||||||
|
-- =====================================================
|
||||||
|
-- Obtiene estadísticas de vinculación para un periodo dado.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_estadisticas_vinculacion(p_periodo TEXT DEFAULT '25-26')
|
||||||
|
RETURNS TABLE (
|
||||||
|
tipo_registro TEXT,
|
||||||
|
total BIGINT,
|
||||||
|
vinculados BIGINT,
|
||||||
|
sin_vincular BIGINT,
|
||||||
|
porcentaje NUMERIC
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
v.tipo_registro,
|
||||||
|
COUNT(*)::BIGINT AS total,
|
||||||
|
COUNT(*)::BIGINT AS vinculados,
|
||||||
|
0::BIGINT AS sin_vincular, -- Se calculará con datos de Metabase
|
||||||
|
100.0 AS porcentaje
|
||||||
|
FROM vinculaciones_externas v
|
||||||
|
WHERE v.periodo_cosecha = p_periodo
|
||||||
|
GROUP BY v.tipo_registro
|
||||||
|
ORDER BY v.tipo_registro;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION get_estadisticas_vinculacion IS 'Retorna estadísticas de vinculación por tipo de registro para un periodo';
|
||||||
|
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- MENSAJES DE ÉXITO
|
||||||
|
-- =====================================================
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE '✓ Esquema de vinculaciones externas creado exitosamente';
|
||||||
|
RAISE NOTICE ' - Tabla vinculaciones_externas creada';
|
||||||
|
RAISE NOTICE ' - Vista vista_lotes_con_vinculaciones creada';
|
||||||
|
RAISE NOTICE ' - Función get_estadisticas_vinculacion() creada';
|
||||||
|
END $$;
|
||||||
290
nuxt4/server/utils/metabase.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* Metabase API Client
|
||||||
|
*
|
||||||
|
* Proporciona funciones para consultar registros externos via Metabase API.
|
||||||
|
* Solo lectura - no modifica datos en la base externa.
|
||||||
|
*
|
||||||
|
* Basado en el patrón de analiticaNucleo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRuntimeConfig } from '#imports'
|
||||||
|
|
||||||
|
// Tipos para las respuestas de Metabase
|
||||||
|
export interface MetabaseQueryResult<T = any> {
|
||||||
|
data: {
|
||||||
|
rows: T[][]
|
||||||
|
cols: { name: string; display_name: string; base_type: string }[]
|
||||||
|
rows_truncated?: number
|
||||||
|
}
|
||||||
|
database_id: number
|
||||||
|
started_at: string
|
||||||
|
status: string
|
||||||
|
row_count: number
|
||||||
|
running_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hace una petición autenticada a la API de Metabase
|
||||||
|
*/
|
||||||
|
export async function metabaseFetch<T = any>(
|
||||||
|
endpoint: string,
|
||||||
|
options: { method?: string; body?: any } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const metabaseUrl = config.metabaseUrl || 'http://metabase:3000'
|
||||||
|
const apiKey = config.metabaseApiKey
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'NUXT_METABASE_API_KEY no está configurada'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-KEY': apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<T>(`${metabaseUrl}${endpoint}`, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Metabase] Request failed for ${endpoint}:`, error?.message || error)
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.message || 'Metabase request failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una consulta SQL nativa en Metabase
|
||||||
|
*/
|
||||||
|
export async function queryMetabaseSQL<T = any>(
|
||||||
|
sql: string,
|
||||||
|
databaseId?: number
|
||||||
|
): Promise<MetabaseQueryResult<T>> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const dbId = databaseId || config.metabaseDatabaseId || 2
|
||||||
|
|
||||||
|
return metabaseFetch<MetabaseQueryResult<T>>('/api/dataset', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
database: dbId,
|
||||||
|
type: 'native',
|
||||||
|
native: {
|
||||||
|
query: sql
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforma una respuesta de Metabase en un array de objetos
|
||||||
|
*/
|
||||||
|
export function transformMetabaseRows<T = Record<string, any>>(
|
||||||
|
result: MetabaseQueryResult
|
||||||
|
): T[] {
|
||||||
|
if (!result.data?.rows || !result.data?.cols) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = result.data.cols
|
||||||
|
|
||||||
|
return result.data.rows.map((row) => {
|
||||||
|
const obj: Record<string, any> = {}
|
||||||
|
cols.forEach((col, index) => {
|
||||||
|
obj[col.name] = row[index]
|
||||||
|
})
|
||||||
|
return obj as T
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// CONSULTAS ESPECÍFICAS PARA REGISTROS EXTERNOS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Período de cosecha en formato de fechas
|
||||||
|
*/
|
||||||
|
export function getPeriodoFechas(periodo: string = '25-26'): { inicio: string; fin: string } {
|
||||||
|
const [yearStart] = periodo.split('-').map((y) => 2000 + parseInt(y))
|
||||||
|
return {
|
||||||
|
inicio: `${yearStart}-09-10`,
|
||||||
|
fin: `${yearStart + 1}-09-09`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los ingresos del período usando la vista existente
|
||||||
|
*/
|
||||||
|
export async function getIngresos(periodo: string = '25-26') {
|
||||||
|
const { inicio, fin } = getPeriodoFechas(periodo)
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
v.id,
|
||||||
|
v.created_at,
|
||||||
|
v.tipo,
|
||||||
|
v.calidad,
|
||||||
|
v.humedad,
|
||||||
|
v.estado,
|
||||||
|
v.observacion,
|
||||||
|
v.sacos_total,
|
||||||
|
v.peso_bruto,
|
||||||
|
v.peso_neto,
|
||||||
|
v.peso_seco,
|
||||||
|
v.cliente_id,
|
||||||
|
v.comercio_id,
|
||||||
|
c.nombre as cliente_nombre,
|
||||||
|
com.nombre as comercio_nombre
|
||||||
|
FROM vista_detalle_ingresos v
|
||||||
|
LEFT JOIN clientes c ON c.id = v.cliente_id
|
||||||
|
LEFT JOIN comercios com ON com.id = v.comercio_id
|
||||||
|
WHERE v.created_at >= '${inicio}'
|
||||||
|
AND v.created_at < '${fin}'
|
||||||
|
AND v.estado != 'anulado'
|
||||||
|
ORDER BY v.created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await queryMetabaseSQL(sql)
|
||||||
|
return transformMetabaseRows(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las carretas del período
|
||||||
|
*/
|
||||||
|
export async function getCarretas(periodo: string = '25-26') {
|
||||||
|
const { inicio, fin } = getPeriodoFechas(periodo)
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
titulo,
|
||||||
|
estado,
|
||||||
|
total as libras_mojadas,
|
||||||
|
humedad,
|
||||||
|
ROUND((total * (100 - COALESCE(humedad, 51)) / 100.0) / 100.0, 2) as qq_seco_estimado
|
||||||
|
FROM carretas
|
||||||
|
WHERE created_at >= '${inicio}'
|
||||||
|
AND created_at < '${fin}'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await queryMetabaseSQL(sql)
|
||||||
|
return transformMetabaseRows(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene las salidas del período
|
||||||
|
*/
|
||||||
|
export async function getSalidas(periodo: string = '25-26') {
|
||||||
|
const { inicio, fin } = getPeriodoFechas(periodo)
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.created_at,
|
||||||
|
s.comprador,
|
||||||
|
s.observacion,
|
||||||
|
s.pesadas,
|
||||||
|
(SELECT SUM((p->>'peso')::numeric) FROM json_array_elements(s.pesadas) p) as peso_bruto,
|
||||||
|
(SELECT SUM((p->>'sacos')::numeric) FROM json_array_elements(s.pesadas) p) as sacos_total
|
||||||
|
FROM salidas s
|
||||||
|
WHERE s.created_at >= '${inicio}'
|
||||||
|
AND s.created_at < '${fin}'
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await queryMetabaseSQL(sql)
|
||||||
|
|
||||||
|
// Calcular peso_neto y qq_seco en el backend
|
||||||
|
return transformMetabaseRows(result).map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
peso_neto: (row.peso_bruto || 0) - ((row.sacos_total || 0) / 2),
|
||||||
|
qq_seco: ((row.peso_bruto || 0) - ((row.sacos_total || 0) / 2)) / 100
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los rechazos del período
|
||||||
|
*/
|
||||||
|
export async function getRechazos(periodo: string = '25-26') {
|
||||||
|
const { inicio, fin } = getPeriodoFechas(periodo)
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.created_at,
|
||||||
|
r.tipo,
|
||||||
|
r.estado,
|
||||||
|
r.cantidad,
|
||||||
|
r.unidad,
|
||||||
|
r.precio_unidad,
|
||||||
|
r.observacion,
|
||||||
|
r.comprador_id,
|
||||||
|
c.nombre as comprador_nombre
|
||||||
|
FROM rechazos r
|
||||||
|
LEFT JOIN clientes c ON c.id = r.comprador_id
|
||||||
|
WHERE r.created_at >= '${inicio}'
|
||||||
|
AND r.created_at < '${fin}'
|
||||||
|
AND r.estado != 'anulado'
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await queryMetabaseSQL(sql)
|
||||||
|
return transformMetabaseRows(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el conteo de registros por tipo para un período
|
||||||
|
*/
|
||||||
|
export async function getConteoRegistros(periodo: string = '25-26') {
|
||||||
|
const { inicio, fin } = getPeriodoFechas(periodo)
|
||||||
|
|
||||||
|
const sqlIngresos = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM vista_detalle_ingresos
|
||||||
|
WHERE created_at >= '${inicio}' AND created_at < '${fin}' AND estado != 'anulado'
|
||||||
|
`
|
||||||
|
|
||||||
|
const sqlCarretas = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM carretas
|
||||||
|
WHERE created_at >= '${inicio}' AND created_at < '${fin}'
|
||||||
|
`
|
||||||
|
|
||||||
|
const sqlSalidas = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM salidas
|
||||||
|
WHERE created_at >= '${inicio}' AND created_at < '${fin}'
|
||||||
|
`
|
||||||
|
|
||||||
|
const sqlRechazos = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM rechazos
|
||||||
|
WHERE created_at >= '${inicio}' AND created_at < '${fin}' AND estado != 'anulado'
|
||||||
|
`
|
||||||
|
|
||||||
|
const [ingresos, carretas, salidas, rechazos] = await Promise.all([
|
||||||
|
queryMetabaseSQL(sqlIngresos),
|
||||||
|
queryMetabaseSQL(sqlCarretas),
|
||||||
|
queryMetabaseSQL(sqlSalidas),
|
||||||
|
queryMetabaseSQL(sqlRechazos)
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
ingresos: parseInt(ingresos.data.rows[0]?.[0] || '0'),
|
||||||
|
carretas: parseInt(carretas.data.rows[0]?.[0] || '0'),
|
||||||
|
salidas: parseInt(salidas.data.rows[0]?.[0] || '0'),
|
||||||
|
rechazos: parseInt(rechazos.data.rows[0]?.[0] || '0')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -450,3 +450,269 @@ export async function getEstadisticasLote(loteId: string): Promise<{
|
|||||||
kg_iniciales: kgIniciales,
|
kg_iniciales: kgIniciales,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// TIPOS PARA VINCULACIONES EXTERNAS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface VinculacionExterna {
|
||||||
|
id: string
|
||||||
|
tipo_registro: 'ingreso' | 'carreta' | 'salida' | 'rechazo'
|
||||||
|
registro_id: number
|
||||||
|
lote_id: string
|
||||||
|
fecha_vinculacion: Date
|
||||||
|
usuario_id: string | null
|
||||||
|
observaciones: string | null
|
||||||
|
datos_cache: Record<string, any> | null
|
||||||
|
periodo_cosecha: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoteConVinculaciones extends Lote {
|
||||||
|
ingresos_vinculados: number
|
||||||
|
carretas_vinculadas: number
|
||||||
|
salidas_vinculadas: number
|
||||||
|
rechazos_vinculados: number
|
||||||
|
total_vinculaciones: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// QUERIES PARA VINCULACIONES EXTERNAS
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todas las vinculaciones con filtros opcionales
|
||||||
|
*/
|
||||||
|
export async function getVinculaciones(filtros?: {
|
||||||
|
tipo_registro?: string
|
||||||
|
lote_id?: string
|
||||||
|
periodo_cosecha?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}): Promise<VinculacionExterna[]> {
|
||||||
|
let sql = 'SELECT * FROM vinculaciones_externas WHERE 1=1'
|
||||||
|
const params: any[] = []
|
||||||
|
let paramCount = 1
|
||||||
|
|
||||||
|
if (filtros?.tipo_registro) {
|
||||||
|
sql += ` AND tipo_registro = $${paramCount}`
|
||||||
|
params.push(filtros.tipo_registro)
|
||||||
|
paramCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtros?.lote_id) {
|
||||||
|
sql += ` AND lote_id = $${paramCount}`
|
||||||
|
params.push(filtros.lote_id)
|
||||||
|
paramCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtros?.periodo_cosecha) {
|
||||||
|
sql += ` AND periodo_cosecha = $${paramCount}`
|
||||||
|
params.push(filtros.periodo_cosecha)
|
||||||
|
paramCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY fecha_vinculacion DESC'
|
||||||
|
|
||||||
|
if (filtros?.limit) {
|
||||||
|
sql += ` LIMIT $${paramCount}`
|
||||||
|
params.push(filtros.limit)
|
||||||
|
paramCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtros?.offset) {
|
||||||
|
sql += ` OFFSET $${paramCount}`
|
||||||
|
params.push(filtros.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<VinculacionExterna>(sql, params)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene vinculaciones por lote
|
||||||
|
*/
|
||||||
|
export async function getVinculacionesByLote(loteId: string): Promise<VinculacionExterna[]> {
|
||||||
|
const result = await query<VinculacionExterna>(
|
||||||
|
'SELECT * FROM vinculaciones_externas WHERE lote_id = $1 ORDER BY tipo_registro, fecha_vinculacion DESC',
|
||||||
|
[loteId]
|
||||||
|
)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene IDs de registros ya vinculados por tipo
|
||||||
|
*/
|
||||||
|
export async function getRegistrosVinculados(
|
||||||
|
tipo: string,
|
||||||
|
periodo: string = '25-26'
|
||||||
|
): Promise<number[]> {
|
||||||
|
const result = await query<{ registro_id: number }>(
|
||||||
|
'SELECT registro_id FROM vinculaciones_externas WHERE tipo_registro = $1 AND periodo_cosecha = $2',
|
||||||
|
[tipo, periodo]
|
||||||
|
)
|
||||||
|
return result.rows.map(r => r.registro_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una nueva vinculación
|
||||||
|
*/
|
||||||
|
export async function createVinculacion(data: {
|
||||||
|
tipo_registro: string
|
||||||
|
registro_id: number
|
||||||
|
lote_id: string
|
||||||
|
usuario_id?: string
|
||||||
|
observaciones?: string
|
||||||
|
datos_cache?: Record<string, any>
|
||||||
|
periodo_cosecha?: string
|
||||||
|
}): Promise<VinculacionExterna> {
|
||||||
|
const result = await query<VinculacionExterna>(
|
||||||
|
`INSERT INTO vinculaciones_externas
|
||||||
|
(tipo_registro, registro_id, lote_id, usuario_id, observaciones, datos_cache, periodo_cosecha)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.tipo_registro,
|
||||||
|
data.registro_id,
|
||||||
|
data.lote_id,
|
||||||
|
data.usuario_id || null,
|
||||||
|
data.observaciones || null,
|
||||||
|
data.datos_cache ? JSON.stringify(data.datos_cache) : null,
|
||||||
|
data.periodo_cosecha || '25-26'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return result.rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea múltiples vinculaciones en una transacción
|
||||||
|
*/
|
||||||
|
export async function createVinculacionesMasivas(
|
||||||
|
vinculaciones: Array<{
|
||||||
|
tipo_registro: string
|
||||||
|
registro_id: number
|
||||||
|
lote_id: string
|
||||||
|
usuario_id?: string
|
||||||
|
observaciones?: string
|
||||||
|
datos_cache?: Record<string, any>
|
||||||
|
periodo_cosecha?: string
|
||||||
|
}>
|
||||||
|
): Promise<VinculacionExterna[]> {
|
||||||
|
const client = await getClient()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
|
||||||
|
const resultados: VinculacionExterna[] = []
|
||||||
|
|
||||||
|
for (const v of vinculaciones) {
|
||||||
|
const result = await client.query<VinculacionExterna>(
|
||||||
|
`INSERT INTO vinculaciones_externas
|
||||||
|
(tipo_registro, registro_id, lote_id, usuario_id, observaciones, datos_cache, periodo_cosecha)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
v.tipo_registro,
|
||||||
|
v.registro_id,
|
||||||
|
v.lote_id,
|
||||||
|
v.usuario_id || null,
|
||||||
|
v.observaciones || null,
|
||||||
|
v.datos_cache ? JSON.stringify(v.datos_cache) : null,
|
||||||
|
v.periodo_cosecha || '25-26'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
resultados.push(result.rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return resultados
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una vinculación
|
||||||
|
*/
|
||||||
|
export async function deleteVinculacion(id: string): Promise<boolean> {
|
||||||
|
const result = await query(
|
||||||
|
'DELETE FROM vinculaciones_externas WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
return (result.rowCount ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene lotes con conteo de vinculaciones
|
||||||
|
*/
|
||||||
|
export async function getLotesConVinculaciones(filtros?: {
|
||||||
|
tipo?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}): Promise<LoteConVinculaciones[]> {
|
||||||
|
let sql = 'SELECT * FROM vista_lotes_con_vinculaciones WHERE 1=1'
|
||||||
|
const params: any[] = []
|
||||||
|
let paramCount = 1
|
||||||
|
|
||||||
|
if (filtros?.tipo) {
|
||||||
|
sql += ` AND tipo = $${paramCount}`
|
||||||
|
params.push(filtros.tipo)
|
||||||
|
paramCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY fecha_creado DESC'
|
||||||
|
|
||||||
|
if (filtros?.limit) {
|
||||||
|
sql += ` LIMIT $${paramCount}`
|
||||||
|
params.push(filtros.limit)
|
||||||
|
paramCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtros?.offset) {
|
||||||
|
sql += ` OFFSET $${paramCount}`
|
||||||
|
params.push(filtros.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<LoteConVinculaciones>(sql, params)
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadísticas de vinculación para un período
|
||||||
|
*/
|
||||||
|
export async function getEstadisticasVinculacion(periodo: string = '25-26'): Promise<{
|
||||||
|
ingreso: { vinculados: number }
|
||||||
|
carreta: { vinculados: number }
|
||||||
|
salida: { vinculados: number }
|
||||||
|
rechazo: { vinculados: number }
|
||||||
|
total_vinculados: number
|
||||||
|
}> {
|
||||||
|
const result = await query<{ tipo_registro: string; count: string }>(
|
||||||
|
`SELECT tipo_registro, COUNT(*) as count
|
||||||
|
FROM vinculaciones_externas
|
||||||
|
WHERE periodo_cosecha = $1
|
||||||
|
GROUP BY tipo_registro`,
|
||||||
|
[periodo]
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
ingreso: { vinculados: 0 },
|
||||||
|
carreta: { vinculados: 0 },
|
||||||
|
salida: { vinculados: 0 },
|
||||||
|
rechazo: { vinculados: 0 },
|
||||||
|
total_vinculados: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const count = parseInt(row.count)
|
||||||
|
if (row.tipo_registro === 'ingreso') stats.ingreso.vinculados = count
|
||||||
|
else if (row.tipo_registro === 'carreta') stats.carreta.vinculados = count
|
||||||
|
else if (row.tipo_registro === 'salida') stats.salida.vinculados = count
|
||||||
|
else if (row.tipo_registro === 'rechazo') stats.rechazo.vinculados = count
|
||||||
|
stats.total_vinculados += count
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|||||||
BIN
pwa_assets/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
pwa_assets/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
pwa_assets/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
pwa_assets/icon-167x167.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
pwa_assets/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 738 B |
BIN
pwa_assets/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
pwa_assets/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
pwa_assets/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
pwa_assets/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
pwa_assets/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
pwa_assets/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
pwa_assets/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
pwa_assets/icon-64x64.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
pwa_assets/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
pwa_assets/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
101
pwa_assets/manifest.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "Mi App PWA",
|
||||||
|
"short_name": "MiApp",
|
||||||
|
"description": "Aplicación progresiva generada automáticamente.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#00000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-180x180.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-167x167.png",
|
||||||
|
"sizes": "167x167",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-64x64.png",
|
||||||
|
"sizes": "64x64",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-48x48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-32x32.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-16x16.png",
|
||||||
|
"sizes": "16x16",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
pwa_assets/service-worker.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
self.addEventListener('install', event => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
// Puedes personalizar la gestión de las solicitudes aquí
|
||||||
|
});
|
||||||