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
This commit is contained in:
2025-11-29 15:25:26 -06:00
parent 1c96b696fa
commit ce8bad68d5
38 changed files with 2987 additions and 1 deletions

View File

@@ -185,6 +185,90 @@
</ClientOnly>
</div>
</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>
</div>
@@ -328,6 +412,14 @@
</UCard>
</template>
</UModal>
<!-- Modal: Asignar Vinculación -->
<VinculacionesModalAsignar
v-model:open="showVinculacionModal"
:tipo="tipoRegistroParaVincular"
:registros="registrosParaVincular"
@vinculado="handleVinculacionSuccess"
/>
</UApp>
</template>
@@ -336,6 +428,9 @@ import type { Lote, Operacion } from '~/composables/useLotes'
const { isAuthenticated } = useAuthentik()
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
const selectedTab = ref('lotes')
@@ -343,6 +438,7 @@ const tabs = [
{ label: 'Lotes', icon: 'i-heroicons-cube', slot: 'lotes', value: 'lotes' },
{ label: 'Operaciones', icon: 'i-heroicons-beaker', slot: 'operaciones', value: 'operaciones' },
{ label: 'Grafos', icon: 'i-heroicons-share', slot: 'grafos', value: 'grafos' },
{ label: 'Externos', icon: 'i-heroicons-link', slot: 'externos', value: 'externos' },
]
// 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 () => {
graphLoading.value = true
graphError.value = null

View 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>

View File

@@ -53,6 +53,85 @@
</div>
</div>
<!-- Sección de Vinculaciones Externas -->
<div class="pt-4 border-t">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-gray-500 font-medium">Registros Externos Vinculados</p>
<UButton
v-if="!vinculacionesLoading && !vinculacionesCargadas"
icon="i-heroicons-link"
size="xs"
variant="outline"
label="Cargar vinculaciones"
@click="cargarVinculaciones"
/>
<UButton
v-if="vinculacionesCargadas"
icon="i-heroicons-arrow-path"
size="xs"
variant="ghost"
@click="cargarVinculaciones"
/>
</div>
<!-- Loading -->
<div v-if="vinculacionesLoading" class="flex items-center gap-2 text-gray-500">
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin" />
<span class="text-sm">Cargando vinculaciones...</span>
</div>
<!-- Contenido de vinculaciones -->
<div v-else-if="vinculacionesCargadas">
<!-- Sin vinculaciones -->
<div v-if="vinculacionesMeta?.total === 0" class="text-center py-4 text-gray-400">
<UIcon name="i-heroicons-link-slash" class="w-8 h-8 mx-auto mb-2" />
<p class="text-sm">No hay registros externos vinculados</p>
</div>
<!-- Con vinculaciones -->
<div v-else class="space-y-3">
<!-- Resumen por tipo -->
<div class="grid grid-cols-4 gap-2">
<div
v-for="(count, tipo) in vinculacionesMeta?.por_tipo"
:key="tipo"
class="text-center p-2 rounded-lg bg-gray-50 dark:bg-gray-800"
>
<UIcon :name="getTipoRegistroIcon(tipo as string)" class="w-5 h-5 mx-auto mb-1" :class="`text-${getTipoRegistroColor(tipo as string)}-500`" />
<p class="text-lg font-bold">{{ count }}</p>
<p class="text-xs text-gray-500 capitalize">{{ tipo }}</p>
</div>
</div>
<!-- Lista de vinculaciones agrupadas -->
<div class="space-y-2">
<template v-for="(items, tipo) in vinculacionesAgrupados" :key="tipo">
<div v-if="items && items.length > 0" class="border rounded-lg p-2">
<p class="text-xs font-medium text-gray-500 mb-1 capitalize flex items-center gap-1">
<UIcon :name="getTipoRegistroIcon(tipo as string)" class="w-3 h-3" />
{{ tipo }} ({{ items.length }})
</p>
<div class="flex flex-wrap gap-1">
<UBadge
v-for="v in items.slice(0, 5)"
:key="v.id"
:color="getTipoRegistroColor(tipo as string)"
variant="subtle"
size="xs"
>
#{{ v.registro_id }}
</UBadge>
<UBadge v-if="items.length > 5" color="gray" variant="subtle" size="xs">
+{{ items.length - 5 }} más
</UBadge>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<div v-if="lote.meta" class="pt-4 border-t">
<p class="text-sm text-gray-500 mb-2">Información Adicional</p>
<UCard class="bg-gray-50 dark:bg-slate-900/70 border border-gray-200 dark:border-slate-800">
@@ -67,6 +146,7 @@
<script setup lang="ts">
import type { Lote } from '~/composables/useLotes'
import type { Vinculacion } from '~/composables/useRegistrosExternos'
const props = defineProps<{
lote: Lote
@@ -78,6 +158,41 @@ const emit = defineEmits<{
}>()
const { TIPOS_LOTE } = useLotes()
const { fetchVinculacionesByLote, getTipoIcon, getTipoColor: getTipoRegistroColor } = useRegistrosExternos()
// Estado de vinculaciones
const vinculacionesLoading = ref(false)
const vinculacionesCargadas = ref(false)
const vinculacionesAgrupados = ref<{
ingresos: Vinculacion[]
carretas: Vinculacion[]
salidas: Vinculacion[]
rechazos: Vinculacion[]
} | null>(null)
const vinculacionesMeta = ref<{
lote_id: string
total: number
por_tipo: {
ingresos: number
carretas: number
salidas: number
rechazos: number
}
} | null>(null)
const cargarVinculaciones = async () => {
vinculacionesLoading.value = true
try {
const result = await fetchVinculacionesByLote(props.lote.id)
vinculacionesAgrupados.value = result.agrupados || null
vinculacionesMeta.value = result.meta || null
vinculacionesCargadas.value = true
} catch (error) {
console.error('Error cargando vinculaciones:', error)
} finally {
vinculacionesLoading.value = false
}
}
const getTipoLabel = (tipo: string) => {
const found = TIPOS_LOTE.find((t) => t.value === tipo)
@@ -98,6 +213,16 @@ const getTipoColor = (tipo: string): string => {
return colorMap[tipo] || 'gray'
}
const getTipoRegistroIcon = (tipo: string): string => {
const iconMap: Record<string, string> = {
ingresos: 'i-heroicons-inbox-arrow-down',
carretas: 'i-heroicons-truck',
salidas: 'i-heroicons-arrow-up-tray',
rechazos: 'i-heroicons-x-circle',
}
return iconMap[tipo] || 'i-heroicons-document'
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
day: '2-digit',
@@ -107,4 +232,9 @@ const formatDate = (dateString: string) => {
minute: '2-digit',
})
}
// Cargar vinculaciones automáticamente al montar
onMounted(() => {
cargarVinculaciones()
})
</script>

View 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>

View 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>

View 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,
}
}