Agregar sistema de vinculaciones con registros externos de Metabase
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 2m46s
- Nuevo schema BD para vinculaciones_externas con constraint único por período - Cliente Metabase para consultar Ingresos, Carretas, Salidas y Rechazos - Endpoints API para registros externos (/api/externos/*) y vinculaciones (/api/vinculaciones/*) - Composable useRegistrosExternos con lógica de vinculación individual y masiva - Componentes: TablaRegistros, ModalAsignar, ProgressDashboard - Tab "Externos" en app.vue con sub-tabs y dashboard de progreso - LotesCard.vue ahora muestra registros vinculados al lote
This commit is contained in:
257
nuxt4/app/components/vinculaciones/ModalAsignar.vue
Normal file
257
nuxt4/app/components/vinculaciones/ModalAsignar.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :title="titulo" class="max-w-2xl">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<!-- Resumen de registros a vincular -->
|
||||
<UCard v-if="registros.length > 0" class="bg-gray-50 dark:bg-gray-800">
|
||||
<div class="text-sm">
|
||||
<p class="font-medium mb-2">
|
||||
Registros a vincular ({{ registros.length }}):
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UBadge
|
||||
v-for="reg in registros.slice(0, 5)"
|
||||
:key="reg.id"
|
||||
:color="tipoColor"
|
||||
variant="subtle"
|
||||
>
|
||||
#{{ reg.id }}
|
||||
</UBadge>
|
||||
<UBadge v-if="registros.length > 5" color="gray" variant="subtle">
|
||||
+{{ registros.length - 5 }} más
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Selector de lote -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Seleccionar lote destino</label>
|
||||
<USelect
|
||||
v-model="loteSeleccionado"
|
||||
:items="lotesOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
searchable
|
||||
search-placeholder="Buscar por código..."
|
||||
placeholder="Selecciona un lote"
|
||||
class="w-full"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="getLoteColor(option.tipo)" size="xs" variant="subtle">
|
||||
{{ option.tipo }}
|
||||
</UBadge>
|
||||
<span class="font-mono">{{ option.codigo || 'Sin código' }}</span>
|
||||
<span v-if="option.cantidad_kg" class="text-gray-500 text-xs">
|
||||
({{ option.cantidad_kg }} kg)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</USelect>
|
||||
</div>
|
||||
|
||||
<!-- O crear nuevo lote -->
|
||||
<div class="text-center text-sm text-gray-500">
|
||||
<span>O</span>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
icon="i-heroicons-plus"
|
||||
label="Crear nuevo lote"
|
||||
variant="outline"
|
||||
block
|
||||
@click="mostrarFormNuevoLote = !mostrarFormNuevoLote"
|
||||
/>
|
||||
|
||||
<!-- Form para nuevo lote -->
|
||||
<div v-if="mostrarFormNuevoLote" class="border rounded-lg p-4 space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Código (opcional)">
|
||||
<UInput v-model="nuevoLote.codigo" placeholder="ej: LOTE-001" />
|
||||
</UFormField>
|
||||
<UFormField label="Tipo" required>
|
||||
<USelect
|
||||
v-model="nuevoLote.tipo"
|
||||
:items="TIPOS_LOTE"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="Selecciona tipo"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Cantidad (kg)">
|
||||
<UInput v-model.number="nuevoLote.cantidad_kg" type="number" placeholder="0.00" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Observaciones -->
|
||||
<UFormField label="Observaciones (opcional)">
|
||||
<UTextarea
|
||||
v-model="observaciones"
|
||||
placeholder="Notas sobre esta vinculación..."
|
||||
rows="2"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
label="Cancelar"
|
||||
variant="outline"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-link"
|
||||
:label="registros.length > 1 ? `Vincular ${registros.length} registros` : 'Vincular'"
|
||||
color="primary"
|
||||
:loading="guardando"
|
||||
:disabled="!puedeGuardar"
|
||||
@click="guardar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TipoRegistro, Lote } from '~/composables/useRegistrosExternos'
|
||||
|
||||
const props = defineProps<{
|
||||
tipo: TipoRegistro
|
||||
registros: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'vinculado': []
|
||||
}>()
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const { vincular, vincularMasivo, getTipoColor } = useRegistrosExternos()
|
||||
const { fetchLotes, createLote, TIPOS_LOTE } = useLotes()
|
||||
|
||||
// Estado
|
||||
const lotes = ref<Lote[]>([])
|
||||
const loteSeleccionado = ref<string | null>(null)
|
||||
const mostrarFormNuevoLote = ref(false)
|
||||
const nuevoLote = ref({
|
||||
codigo: '',
|
||||
tipo: '',
|
||||
cantidad_kg: null as number | null,
|
||||
})
|
||||
const observaciones = ref('')
|
||||
const guardando = ref(false)
|
||||
|
||||
// Computed
|
||||
const titulo = computed(() => {
|
||||
const tipoLabel = props.tipo.charAt(0).toUpperCase() + props.tipo.slice(1)
|
||||
return props.registros.length > 1
|
||||
? `Vincular ${props.registros.length} ${tipoLabel}s a lote`
|
||||
: `Vincular ${tipoLabel} a lote`
|
||||
})
|
||||
|
||||
const tipoColor = computed(() => getTipoColor(props.tipo))
|
||||
|
||||
const lotesOptions = computed(() =>
|
||||
lotes.value.map(l => ({
|
||||
value: l.id,
|
||||
label: l.codigo || `Lote ${l.id.slice(0, 8)}...`,
|
||||
codigo: l.codigo,
|
||||
tipo: l.tipo,
|
||||
cantidad_kg: l.cantidad_kg,
|
||||
}))
|
||||
)
|
||||
|
||||
const puedeGuardar = computed(() => {
|
||||
if (mostrarFormNuevoLote.value) {
|
||||
return nuevoLote.value.tipo !== ''
|
||||
}
|
||||
return loteSeleccionado.value !== null
|
||||
})
|
||||
|
||||
// Funciones
|
||||
const getLoteColor = (tipo: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
uva: 'purple',
|
||||
despulpado_primera: 'green',
|
||||
despulpado_segunda: 'yellow',
|
||||
despulpado_rechazos: 'red',
|
||||
oreado: 'orange',
|
||||
presecado: 'amber',
|
||||
reposo: 'blue',
|
||||
secado: 'emerald',
|
||||
}
|
||||
return colorMap[tipo] || 'gray'
|
||||
}
|
||||
|
||||
const cargarLotes = async () => {
|
||||
const resultado = await fetchLotes({ limit: 100 })
|
||||
lotes.value = resultado
|
||||
}
|
||||
|
||||
const guardar = async () => {
|
||||
guardando.value = true
|
||||
|
||||
try {
|
||||
let loteId = loteSeleccionado.value
|
||||
|
||||
// Si se quiere crear un nuevo lote
|
||||
if (mostrarFormNuevoLote.value && nuevoLote.value.tipo) {
|
||||
const nuevoLoteCreado = await createLote({
|
||||
codigo: nuevoLote.value.codigo || undefined,
|
||||
tipo: nuevoLote.value.tipo,
|
||||
cantidad_kg: nuevoLote.value.cantidad_kg || undefined,
|
||||
})
|
||||
|
||||
if (!nuevoLoteCreado) {
|
||||
throw new Error('No se pudo crear el lote')
|
||||
}
|
||||
|
||||
loteId = nuevoLoteCreado.id
|
||||
}
|
||||
|
||||
if (!loteId) {
|
||||
throw new Error('Debes seleccionar o crear un lote')
|
||||
}
|
||||
|
||||
// Vincular registros
|
||||
if (props.registros.length === 1) {
|
||||
await vincular(props.tipo, props.registros[0].id, loteId, {
|
||||
observaciones: observaciones.value || undefined,
|
||||
datosCache: props.registros[0],
|
||||
})
|
||||
} else {
|
||||
await vincularMasivo(
|
||||
props.registros.map(r => ({
|
||||
tipo: props.tipo,
|
||||
registroId: r.id,
|
||||
loteId: loteId!,
|
||||
observaciones: observaciones.value || undefined,
|
||||
datosCache: r,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
emit('vinculado')
|
||||
isOpen.value = false
|
||||
} catch (error: any) {
|
||||
console.error('Error vinculando:', error)
|
||||
} finally {
|
||||
guardando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset al abrir
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
loteSeleccionado.value = null
|
||||
mostrarFormNuevoLote.value = false
|
||||
nuevoLote.value = { codigo: '', tipo: '', cantidad_kg: null }
|
||||
observaciones.value = ''
|
||||
cargarLotes()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user