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
234 lines
6.9 KiB
Vue
234 lines
6.9 KiB
Vue
<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>
|