Files
seguidorDeLotes/nuxt4/app/app.vue
josedario87 fe1b49c108
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m6s
Agregar funcionalidad de importación de backups
Implementa la capacidad de restaurar la base de datos desde
un archivo SQL de backup exportado previamente.

Cambios:

Backend:
- Nuevo endpoint POST /api/debug/import-database.post.ts
- Recibe archivo .sql via multipart/form-data
- Ejecuta el SQL del backup (que incluye DROP y CREATE)
- Reemplaza completamente la base de datos existente

Frontend:
- Nuevo botón verde "📥 IMPORTAR BACKUP"
- Input file oculto con accept=".sql"
- Validación de extensión de archivo
- Confirmación con advertencia clara
- Estado de carga durante importación

Flujo de uso:
1. Usuario hace click en "📥 IMPORTAR BACKUP"
2. Selecciona archivo .sql previamente exportado
3. Confirma que desea reemplazar toda la BD
4. Sistema ejecuta el SQL completo
5. Muestra mensaje de éxito/error
6. Usuario recarga la página

Esto completa el ciclo backup/restore permitiendo
recuperación completa del estado de la base de datos.
2025-11-22 04:22:50 -06:00

724 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<UApp>
<NuxtRouteAnnouncer />
<UNotifications />
<UContainer class="py-8">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-primary-600 dark:text-primary-400 flex items-center gap-2">
<NuxtImg src="/icon-64x64.png" alt="Logo Seguidor de Lotes" width="32" height="32" />
Seguidor de Lotes
</h1>
<p class="text-gray-600 dark:text-gray-400 text-lg">
Sistema de trazabilidad y gestión de lotes de café
</p>
</div>
<div class="flex items-center gap-3">
<AuthUserAvatar v-if="isAuthenticated" />
<AuthLogoutButton v-if="isAuthenticated" />
</div>
</div>
<!-- Botones de Prueba API -->
<UCard class="mb-4">
<div class="flex gap-2 flex-wrap">
<UButton @click="testGetLotes" color="primary">
Probar GET /api/lotes
</UButton>
<UButton @click="testGetOperaciones" color="primary">
Probar GET /api/operaciones
</UButton>
<UButton @click="testGetTrazabilidad" color="primary">
Probar Trazabilidad
</UButton>
</div>
<p class="text-sm text-gray-500 mt-2">
Los resultados se mostrarán en la consola del navegador (F12)
</p>
</UCard>
<!-- BOTONES DE DEBUG - TEMPORALES -->
<!-- NO ELIMINAR SIN CONSULTAR A DARIO/DRAGANEL/NUCLEO000 -->
<UCard class="mb-4 border-2 border-red-500 bg-red-50 dark:bg-red-950">
<div class="flex items-center gap-2 mb-3">
<UIcon name="i-heroicons-exclamation-triangle" class="w-6 h-6 text-red-600" />
<h3 class="text-lg font-bold text-red-600">
DEBUG - BOTONES TEMPORALES
</h3>
</div>
<p class="text-sm text-red-700 dark:text-red-400 mb-3">
<strong>ADVERTENCIA:</strong> Estos botones modifican la base de datos directamente.
<br />
<strong>NO ELIMINAR</strong> este código sin consultar a Dario/Draganel/nucleo000.
</p>
<div class="flex gap-2 flex-wrap">
<UButton
@click="resetDatabase"
color="red"
variant="solid"
:loading="resettingDB"
>
🗑 BORRAR TODA LA BD
</UButton>
<UButton
@click="seedDatabase"
color="orange"
variant="solid"
:loading="seedingDB"
>
🌱 CARGAR DATOS DE EJEMPLO
</UButton>
<UButton
@click="clearData"
color="yellow"
variant="solid"
:loading="clearingData"
>
🧹 LIMPIAR DATOS (solo datos)
</UButton>
<UButton
@click="exportDatabase"
color="blue"
variant="solid"
:loading="exportingDB"
>
💾 EXPORTAR BACKUP
</UButton>
<UButton
@click="triggerFileInput"
color="green"
variant="solid"
:loading="importingDB"
>
📥 IMPORTAR BACKUP
</UButton>
<input
ref="fileInputRef"
type="file"
accept=".sql"
@change="importDatabase"
style="display: none"
/>
</div>
<p class="text-xs text-red-600 dark:text-red-400 mt-2">
Resultados en consola (F12). Recarga la página después de usar estos botones.
</p>
</UCard>
<!-- FIN BOTONES DE DEBUG -->
<!-- Contenido principal -->
<div v-if="isAuthenticated">
<!-- Navegación por Tabs -->
<UTabs v-model="selectedTab" :items="tabs" class="mb-6">
<!-- Tab: Lotes -->
<template #lotes>
<div class="py-4">
<h3>Contenido del Tab Lotes</h3>
<ClientOnly>
<LotesTable
@create="showCreateLoteModal = true"
@view="handleViewLote"
@edit="handleEditLote"
@trazabilidad="handleViewTrazabilidad"
/>
</ClientOnly>
</div>
</template>
<!-- Tab: Operaciones -->
<template #operaciones>
<div class="py-4">
<h3>Contenido del Tab Operaciones</h3>
<ClientOnly>
<OperacionesTable
@create="showCreateOperacionModal = true"
@view="handleViewOperacion"
/>
</ClientOnly>
</div>
</template>
<!-- Tab: Grafos -->
<template #grafos>
<div class="py-4 space-y-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<h3 class="text-xl font-semibold">Grafo de trazabilidad</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Visualiza el grafo completo desde un lote final (sin hijos). Solo se muestran lotes que no han sido procesados.</p>
</div>
<div class="flex gap-2 items-center">
<USelect
v-model="selectedGraphLoteId"
:items="graphSelectItems"
label-key="label"
value-key="value"
searchable
placeholder="Selecciona lote"
class="w-72"
:loading="graphLoading"
:disabled="graphLoading || graphSelectItems.length === 0"
/>
<UButton icon="i-heroicons-arrow-path" variant="outline" @click="loadGraphLotes">
Recargar
</UButton>
</div>
</div>
<UAlert v-if="graphError" color="red" variant="soft" title="Error cargando lotes">
{{ graphError }}
</UAlert>
<ClientOnly>
<div v-if="selectedGraphLoteId" class="mt-2">
<LotesTrazabilidad
:key="selectedGraphLoteId"
:lote-id="selectedGraphLoteId"
@close="selectedGraphLoteId = null"
/>
</div>
<template #fallback>
<USkeleton class="h-64" />
</template>
</ClientOnly>
</div>
</template>
</UTabs>
</div>
<!-- Mensaje si no está autenticado -->
<UCard v-else class="text-center">
<div class="py-8">
<UIcon name="i-heroicons-shield-exclamation" class="w-16 h-16 mx-auto mb-4 text-gray-400" />
<h2 class="text-2xl font-semibold mb-2">No autenticado</h2>
<p class="text-gray-600 dark:text-gray-400">
Authentik Proxy Outpost debería redirigirte automáticamente.
</p>
</div>
</UCard>
</div>
</UContainer>
<!-- Modal: Crear/Editar Lote -->
<UModal v-model:open="showLoteFormModal">
<template #content>
<LotesForm
:lote="selectedLote"
@cancel="closeLoteFormModal"
@success="handleLoteFormSuccess"
/>
</template>
</UModal>
<!-- Modal: Ver Detalle de Lote -->
<UModal v-model:open="showLoteDetailModal">
<template #content>
<LotesCard
v-if="selectedLote"
:lote="selectedLote"
@edit="handleEditLoteFromDetail"
@trazabilidad="handleViewTrazabilidadFromDetail"
/>
</template>
</UModal>
<!-- Modal: Ver Trazabilidad -->
<UModal
v-model:open="showTrazabilidadModal"
:ui="{
content: 'w-[calc(100vw-2rem)] max-w-4xl rounded-lg shadow-lg ring ring-default max-h-[80vh]'
}"
>
<template #header>
<div class="flex justify-between items-center">
<div>
<h3 class="text-xl font-bold">Trazabilidad de Lote</h3>
<p class="text-sm text-gray-500">Historial completo desde los ingresos iniciales</p>
</div>
<UButton
icon="i-heroicons-x-mark"
variant="ghost"
size="sm"
@click="showTrazabilidadModal = false"
/>
</div>
</template>
<template #body>
<LotesTrazabilidad
v-if="trazabilidadLoteId"
:lote-id="trazabilidadLoteId"
/>
</template>
</UModal>
<!-- Modal: Crear Operación -->
<UModal
v-model:open="showCreateOperacionModal"
:ui="{ content: 'w-[calc(100vw-2rem)] max-w-3xl rounded-lg shadow-lg ring ring-default max-h-[80vh]' }"
>
<template #header>
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Flujo guiado</p>
<h3 class="text-xl font-semibold">Nueva Operación</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Define el tipo, selecciona inputs y crea los outputs.</p>
</div>
<UButton
icon="i-heroicons-x-mark"
variant="ghost"
size="sm"
@click="showCreateOperacionModal = false"
/>
</div>
</template>
<template #body>
<OperacionesForm
@cancel="showCreateOperacionModal = false"
@success="handleOperacionFormSuccess"
/>
</template>
</UModal>
<!-- Modal: Ver Detalle de Operación -->
<UModal
v-model:open="showOperacionDetailModal"
:ui="{ content: 'w-[calc(100vw-2rem)] max-w-2xl rounded-lg shadow-lg ring ring-default' }"
>
<template #content>
<UCard v-if="selectedOperacion">
<template #header>
<div class="flex justify-between items-center">
<div>
<h3 class="text-lg font-semibold">Detalle de Operación</h3>
<p class="text-sm text-gray-500">
{{ getOperacionTipoLabel(selectedOperacion.tipo) }} · {{ formatDate(selectedOperacion.fecha) }}
</p>
</div>
<UButton icon="i-heroicons-x-mark" variant="ghost" @click="closeOperacionDetailModal" />
</div>
</template>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-xs uppercase text-gray-500">ID</p>
<p class="font-mono text-sm">{{ selectedOperacion.id }}</p>
</div>
<div>
<p class="text-xs uppercase text-gray-500">Fecha</p>
<p class="text-sm">{{ formatDate(selectedOperacion.fecha) }}</p>
</div>
<div class="col-span-2">
<p class="text-xs uppercase text-gray-500">Tipo</p>
<UBadge color="blue" variant="subtle">{{ getOperacionTipoLabel(selectedOperacion.tipo) }}</UBadge>
</div>
</div>
<div>
<p class="text-xs uppercase text-gray-500 mb-1">Meta</p>
<div class="rounded-lg border border-gray-200 dark:border-slate-800 bg-gray-50 dark:bg-slate-900/60 p-2">
<pre class="text-xs text-gray-800 dark:text-slate-200 whitespace-pre-wrap overflow-x-auto">
{{ JSON.stringify(selectedOperacion.meta || {}, null, 2) }}
</pre>
</div>
</div>
</div>
</UCard>
</template>
</UModal>
</UApp>
</template>
<script setup lang="ts">
import type { Lote, Operacion } from '~/composables/useLotes'
const { isAuthenticated } = useAuthentik()
const { fetchLotes: fetchLotesComposable, TIPOS_LOTE, TIPOS_OPERACION } = useLotes()
// Navegación
const selectedTab = ref('lotes')
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' },
]
// Estados de modales
const showLoteFormModal = ref(false)
const showLoteDetailModal = ref(false)
const showTrazabilidadModal = ref(false)
const showCreateLoteModal = ref(false)
const showCreateOperacionModal = ref(false)
const showOperacionDetailModal = ref(false)
// Estados de datos
const selectedLote = ref<Lote | null>(null)
const selectedOperacion = ref<Operacion | null>(null)
const trazabilidadLoteId = ref<string | null>(null)
const graphLotes = ref<Lote[]>([])
const graphLoading = ref(false)
const graphError = ref<string | null>(null)
const selectedGraphLoteId = ref<string | null>(null)
// Handlers para Lotes
const handleViewLote = (lote: Lote) => {
selectedLote.value = lote
showLoteDetailModal.value = true
}
const handleEditLote = (lote: Lote) => {
selectedLote.value = lote
showLoteFormModal.value = true
}
const handleEditLoteFromDetail = () => {
showLoteDetailModal.value = false
showLoteFormModal.value = true
}
const handleViewTrazabilidad = (lote: Lote) => {
trazabilidadLoteId.value = lote.id
showTrazabilidadModal.value = true
}
const handleViewTrazabilidadFromDetail = () => {
if (selectedLote.value) {
showLoteDetailModal.value = false
trazabilidadLoteId.value = selectedLote.value.id
showTrazabilidadModal.value = true
}
}
const closeLoteFormModal = () => {
showLoteFormModal.value = false
selectedLote.value = null
}
const handleLoteFormSuccess = () => {
closeLoteFormModal()
// La tabla se recargará automáticamente
}
// Handlers para Operaciones
const handleViewOperacion = (operacion: Operacion) => {
selectedOperacion.value = operacion
showOperacionDetailModal.value = true
}
const handleOperacionFormSuccess = () => {
showCreateOperacionModal.value = false
// Las tablas se recargarán automáticamente
}
const closeOperacionDetailModal = () => {
showOperacionDetailModal.value = false
selectedOperacion.value = null
}
const getTipoLabel = (tipo: string) => {
const found = TIPOS_LOTE.find((t) => t.value === tipo)
return found?.label || tipo
}
const getOperacionTipoLabel = (tipo: string) => {
const found = TIPOS_OPERACION.find((t) => t.value === tipo)
return found?.label || tipo
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Datos para grafos
const graphSelectItems = computed(() =>
graphLotes.value.map((lote) => ({
label: `${lote.codigo || lote.id} · ${getTipoLabel(lote.tipo)}`,
value: lote.id,
}))
)
const loadGraphLotes = async () => {
graphLoading.value = true
graphError.value = null
try {
// Filtrar solo lotes finales (sin hijos) para el grafo
const lotes = await fetchLotesComposable({ soloFinales: true })
graphLotes.value = lotes.sort((a, b) =>
new Date(b.fecha_creado).getTime() - new Date(a.fecha_creado).getTime()
)
if (!selectedGraphLoteId.value && graphLotes.value.length > 0) {
selectedGraphLoteId.value = graphLotes.value[0].id
}
} catch (err: any) {
graphError.value = err?.message || 'Error cargando lotes'
} finally {
graphLoading.value = false
}
}
onMounted(() => {
loadGraphLotes()
})
// ⚠️⚠️⚠️ FUNCIONES DE DEBUG - TEMPORALES ⚠️⚠️⚠️
// NO ELIMINAR SIN CONSULTAR A DARIO/DRAGANEL/NUCLEO000
const resettingDB = ref(false)
const seedingDB = ref(false)
const clearingData = ref(false)
const exportingDB = ref(false)
const importingDB = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const resetDatabase = async () => {
if (!confirm('⚠️ ADVERTENCIA: Esto BORRARÁ TODOS LOS DATOS de la base de datos.\n\n¿Estás seguro de continuar?')) {
return
}
console.log('🗑️ === RESETEANDO BASE DE DATOS ===')
resettingDB.value = true
try {
const response = await fetch('/api/debug/reset-database', {
method: 'POST',
})
const data = await response.json()
console.log('Status:', response.status)
console.log('Respuesta:', data)
if (data.success) {
alert('✅ Base de datos reseteada exitosamente.\n\nRecarga la página para ver los cambios.')
}
} catch (error) {
console.error('❌ Error:', error)
alert('❌ Error reseteando la base de datos. Ver consola.')
} finally {
resettingDB.value = false
}
}
const seedDatabase = async () => {
console.log('🌱 === CARGANDO DATOS DE EJEMPLO ===')
seedingDB.value = true
try {
const response = await fetch('/api/debug/seed-database', {
method: 'POST',
})
const data = await response.json()
console.log('Status:', response.status)
console.log('Respuesta:', data)
if (data.success) {
alert('✅ Datos de ejemplo cargados exitosamente.\n\nRecarga la página para ver los cambios.')
}
} catch (error) {
console.error('❌ Error:', error)
alert('❌ Error cargando datos. Ver consola.')
} finally {
seedingDB.value = false
}
}
const clearData = async () => {
if (!confirm('⚠️ ADVERTENCIA: Esto ELIMINARÁ TODOS LOS DATOS de las tablas (TRUNCATE) pero mantendrá la estructura.\n\n¿Estás seguro de continuar?')) {
return
}
console.log('🧹 === LIMPIANDO DATOS DE TABLAS ===')
clearingData.value = true
try {
const response = await fetch('/api/debug/clear-data', {
method: 'POST',
})
const data = await response.json()
console.log('Status:', response.status)
console.log('Respuesta:', data)
if (data.success) {
alert('✅ Datos eliminados exitosamente. Las tablas están vacías.\n\nRecarga la página para ver los cambios.')
}
} catch (error) {
console.error('❌ Error:', error)
alert('❌ Error limpiando datos. Ver consola.')
} finally {
clearingData.value = false
}
}
const exportDatabase = async () => {
console.log('💾 === EXPORTANDO BACKUP DE BASE DE DATOS ===')
exportingDB.value = true
try {
const response = await fetch('/api/debug/export-database', {
method: 'POST',
})
if (!response.ok) {
throw new Error('Error en la exportación')
}
// Descargar el archivo SQL
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
// Nombre del archivo con timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
a.download = `backup-seguidordelotes-${timestamp}.sql`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
console.log('✅ Backup descargado exitosamente')
alert('✅ Backup de base de datos descargado exitosamente.')
} catch (error) {
console.error('❌ Error:', error)
alert('❌ Error exportando backup. Ver consola.')
} finally {
exportingDB.value = false
}
}
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const importDatabase = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
return
}
if (!file.name.endsWith('.sql')) {
alert('⚠️ Por favor selecciona un archivo .sql')
input.value = ''
return
}
if (!confirm('⚠️ ADVERTENCIA: Esto REEMPLAZARÁ TODA LA BASE DE DATOS con el contenido del backup.\n\nSe eliminará todo lo existente y se cargará el backup.\n\n¿Estás seguro de continuar?')) {
input.value = ''
return
}
console.log('📥 === IMPORTANDO BACKUP DE BASE DE DATOS ===')
console.log(` - Archivo: ${file.name}`)
console.log(` - Tamaño: ${(file.size / 1024).toFixed(2)} KB`)
importingDB.value = true
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/debug/import-database', {
method: 'POST',
body: formData,
})
const data = await response.json()
console.log('Status:', response.status)
console.log('Respuesta:', data)
if (data.success) {
alert('✅ Backup importado exitosamente.\n\nLa base de datos ha sido restaurada.\n\nRecarga la página para ver los cambios.')
} else {
throw new Error(data.message || 'Error desconocido')
}
} catch (error: any) {
console.error('❌ Error:', error)
alert(`❌ Error importando backup: ${error.message || 'Ver consola para más detalles'}`)
} finally {
importingDB.value = false
input.value = '' // Limpiar el input
}
}
// ⚠️⚠️⚠️ FIN FUNCIONES DE DEBUG ⚠️⚠️⚠️
// Funciones de prueba de API
const testGetLotes = async () => {
console.log('=== Probando GET /api/lotes ===')
try {
const response = await fetch('/api/lotes')
const data = await response.json()
console.log('Status:', response.status)
console.log('Datos recibidos:', data)
} catch (error) {
console.error('Error:', error)
}
}
const testGetOperaciones = async () => {
console.log('=== Probando GET /api/operaciones ===')
try {
const response = await fetch('/api/operaciones')
const data = await response.json()
console.log('Status:', response.status)
console.log('Datos recibidos:', data)
} catch (error) {
console.error('Error:', error)
}
}
const testGetTrazabilidad = async () => {
console.log('=== Probando Trazabilidad ===')
try {
// Primero obtener lotes para tener un ID
const lotesResponse = await fetch('/api/lotes')
const lotesData = await lotesResponse.json()
console.log('Lotes disponibles:', lotesData)
if (lotesData.data && lotesData.data.length > 0) {
const primerLoteId = lotesData.data[0].id
console.log('Obteniendo trazabilidad para lote:', primerLoteId)
const trazResponse = await fetch(`/api/lotes/${primerLoteId}/trazabilidad`)
const trazData = await trazResponse.json()
console.log('Status:', trazResponse.status)
console.log('Trazabilidad:', trazData)
}
} catch (error) {
console.error('Error:', error)
}
}
// Watch para crear lote
watch(showCreateLoteModal, (value) => {
if (value) {
selectedLote.value = null
showLoteFormModal.value = true
showCreateLoteModal.value = false
}
})
// Configurar meta tags para PWA
useHead({
title: 'Seguidor de Lotes - Trazabilidad de Café',
link: [
{ rel: 'manifest', href: '/manifest.webmanifest' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/icon-32x32.png' },
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/icon-16x16.png' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }
],
meta: [
{ name: 'theme-color', content: '#16a34a' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' }
]
})
</script>