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:
534
nuxt4/app/composables/useRegistrosExternos.ts
Normal file
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user