Files
cataRio/nuxt4/app/pages/cata/sesion.vue
josedario87 60287f35dc
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m2s
Debug: Cambiar a slot #content y agregar debug de datos
- Cambiar de slot #body a #content
- Agregar debug para ver qué contiene item cuando no tiene muestra
- Esto ayudará a entender qué datos recibe el slot
2025-10-18 02:36:11 -06:00

432 lines
12 KiB
Vue

<template>
<div class="cata-page cata-text min-h-screen">
<!-- Loading State -->
<div v-if="cargando" class="flex justify-center items-center py-20">
<div class="loading-spinner"></div>
</div>
<!-- Error State -->
<div v-else-if="error || !sesionActiva" class="container mx-auto px-4 py-8">
<div class="cata-outline-box p-6 rounded-lg max-w-md mx-auto text-center">
<UIcon name="i-lucide-alert-circle" class="w-12 h-12 mx-auto mb-4 text-error" />
<h2 class="text-xl font-semibold mb-2 cata-text">
Error
</h2>
<p class="text-sm cata-text opacity-75 mb-4">
No se pudo cargar la sesión de catación
</p>
<NuxtLink to="/cata" class="cata-button inline-block">
Volver al inicio
</NuxtLink>
</div>
</div>
<!-- Main Content -->
<div v-else class="sesion-container">
<!-- Header Sticky -->
<div class="sesion-header sticky top-0 z-10 cata-page border-b">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between mb-4">
<!-- Título y navegación -->
<div class="flex items-center gap-4">
<NuxtLink to="/cata" class="cata-button p-2">
<UIcon name="i-lucide-arrow-left" class="w-5 h-5" />
</NuxtLink>
<div>
<h1 class="text-xl sm:text-2xl font-bold cata-text dark:cata-glow">
Sesión de Catación
</h1>
<p class="text-xs sm:text-sm cata-text opacity-75">
{{ sesionActiva.catador }} - {{ formatearFecha(sesionActiva.fecha) }}
</p>
</div>
</div>
<!-- Botones de acción -->
<div class="flex items-center gap-2">
<button
class="cata-button p-2 hidden sm:block"
title="Exportar sesión"
@click="exportar"
>
<UIcon name="i-lucide-download" class="w-5 h-5" />
</button>
<button
class="cata-button p-2"
title="Menú"
@click="mostrarMenu = !mostrarMenu"
>
<UIcon name="i-lucide-menu" class="w-5 h-5" />
</button>
</div>
</div>
<!-- Estadísticas rápidas -->
<div v-if="estadisticasSesion" class="stats-bar flex gap-4 text-xs sm:text-sm">
<div class="stat-item">
<span class="stat-label cata-text opacity-60">Muestras:</span>
<span class="stat-value cata-text font-semibold">{{ estadisticasSesion.totalMuestras }}</span>
</div>
<div class="stat-item">
<span class="stat-label cata-text opacity-60">Completas:</span>
<span class="stat-value cata-text font-semibold">{{ estadisticasSesion.muestrasCompletas }}</span>
</div>
<div class="stat-item">
<span class="stat-label cata-text opacity-60">Progreso:</span>
<span class="stat-value cata-text font-semibold">{{ estadisticasSesion.porcentajeCompletitud }}%</span>
</div>
<div class="stat-item">
<span class="stat-label cata-text opacity-60">Puntaje Prom:</span>
<span class="stat-value cata-text font-semibold">{{ estadisticasSesion.puntajePromedio }}</span>
</div>
</div>
<!-- Menú desplegable -->
<div v-if="mostrarMenu" class="menu-desplegable mt-4 cata-outline-box p-3 rounded-md">
<button
class="menu-item cata-text w-full text-left px-3 py-2 hover:bg-primary/10 rounded"
@click="exportar"
>
<UIcon name="i-lucide-download" class="w-4 h-4 inline mr-2" />
Exportar Sesión
</button>
<button
class="menu-item cata-text w-full text-left px-3 py-2 hover:bg-primary/10 rounded text-error"
@click="confirmarEliminar"
>
<UIcon name="i-lucide-trash-2" class="w-4 h-4 inline mr-2" />
Eliminar Sesión
</button>
</div>
</div>
<!-- Tabs -->
<div class="tabs-container border-t">
<div class="container mx-auto px-4">
<div class="flex overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.value"
:class="[
'cata-tab',
{ 'cata-tab-active': tabActiva === tab.value },
]"
@click="cambiarTab(tab.value)"
>
<UIcon :name="tab.icon" class="w-4 h-4 inline mr-2" />
{{ tab.label }}
</button>
</div>
</div>
</div>
</div>
<!-- Accordions de Muestras -->
<div class="muestras-container container mx-auto px-4 py-6">
<UAccordion
v-model="accordionAbierto"
type="multiple"
:items="accordionItems"
:ui="{
item: 'mb-4 last:mb-0 cata-outline-box rounded-lg overflow-hidden',
trigger: 'w-full px-4 py-3 hover:bg-primary/5 transition-colors',
content: 'border-t border-default',
}"
>
<!-- Header personalizado con ResumenMuestra -->
<template #default="{ item }">
<CataResumenMuestra
:muestra="item.muestra"
:porcentaje-completitud="porcentajeCompletitud(item.muestra)"
/>
</template>
<!-- Content con FormularioMuestra -->
<template #content="{ item }">
<div v-if="item && item.muestra" class="p-4">
<CataFormularioMuestra
:muestra="item.muestra"
:tab-activa="tabActiva"
/>
</div>
<div v-else class="p-4 text-error">
Debug: item={{ JSON.stringify(item) }}
</div>
</template>
</UAccordion>
</div>
<!-- Botón flotante de finalizar -->
<div class="floating-action">
<button
class="cata-button px-6 py-3 shadow-lg"
@click="finalizarSesion"
>
<UIcon name="i-lucide-check-circle" class="w-5 h-5 inline mr-2" />
Finalizar Sesión
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { TabCatacion } from '~/composables/useCatacion'
import type { Muestra } from '~/types/catacion'
const {
sesionActiva,
cargando,
error,
tabActiva,
accordionAbierto,
estadisticasSesion,
exportarSesion,
eliminarSesionActual,
porcentajeCompletitud,
} = useCatacion()
const { inicializar } = useIndexedDB()
// Estado del menú
const mostrarMenu = ref(false)
// Definición de tabs
const tabs = [
{
value: 'fragancia-aroma' as TabCatacion,
label: 'Fragancia/Aroma',
icon: 'i-lucide-flower-2',
},
{
value: 'sabor' as TabCatacion,
label: 'Sabor',
icon: 'i-lucide-coffee',
},
{
value: 'impresion-global' as TabCatacion,
label: 'Impresión Global',
icon: 'i-lucide-star',
},
]
// Items del accordion
const accordionItems = computed(() => {
if (!sesionActiva.value) return []
return sesionActiva.value.muestras.map((muestra) => ({
label: '', // Usaremos slot #default para el contenido del header
value: `muestra-${muestra.muestraId}`,
muestra: JSON.parse(JSON.stringify(muestra)) as Muestra, // Datos extra para el template
}))
})
// Inicializar al montar
onMounted(async () => {
await inicializar()
// Redirigir si no hay sesión
if (!sesionActiva.value) {
navigateTo('/cata')
}
})
// Cambiar tab
const cambiarTab = (tab: TabCatacion) => {
tabActiva.value = tab
mostrarMenu.value = false
}
// Formatear fecha
const formatearFecha = (fecha: string): string => {
const date = new Date(fecha)
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
}
// Exportar sesión
const exportar = () => {
const json = exportarSesion()
if (!json) return
// Crear blob y descargar
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `catacion-${sesionActiva.value?.fecha || 'sesion'}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
mostrarMenu.value = false
}
// Confirmar eliminación
const confirmarEliminar = () => {
const confirmar = window.confirm(
'¿Estás seguro de que quieres eliminar esta sesión? Esta acción no se puede deshacer.'
)
if (confirmar) {
eliminarSesionActual()
navigateTo('/cata')
}
mostrarMenu.value = false
}
// Finalizar sesión
const finalizarSesion = () => {
if (!estadisticasSesion.value) return
const { muestrasCompletas, totalMuestras, porcentajeCompletitud } = estadisticasSesion.value
if (porcentajeCompletitud < 100) {
const confirmar = window.confirm(
`La sesión está ${porcentajeCompletitud}% completa (${muestrasCompletas}/${totalMuestras} muestras). ¿Deseas finalizarla de todos modos?`
)
if (!confirmar) return
}
// Exportar automáticamente al finalizar
exportar()
// Preguntar si desea eliminar la sesión
const eliminar = window.confirm(
'¿Deseas eliminar la sesión de la base de datos local? La sesión ya fue exportada.'
)
if (eliminar) {
eliminarSesionActual()
navigateTo('/cata')
} else {
navigateTo('/cata')
}
}
// Título de la página
useHead({
title: 'RioCata - Sesión de Catación',
})
// Cerrar menú al hacer click fuera
onMounted(() => {
const handleClickOutside = (event: MouseEvent) => {
const menu = document.querySelector('.menu-desplegable')
const menuButton = document.querySelector('[title="Menú"]')
if (
menu &&
menuButton &&
!menu.contains(event.target as Node) &&
!menuButton.contains(event.target as Node)
) {
mostrarMenu.value = false
}
}
document.addEventListener('click', handleClickOutside)
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
})
</script>
<style scoped>
.sesion-header {
background-color: var(--cata-bg);
border-color: color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
.tabs-container {
border-color: color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
.stat-item {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
/* Floating action button */
.floating-action {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 20;
}
.floating-action button {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dark .floating-action button {
box-shadow: 0 4px 12px color-mix(in srgb, var(--cata-primary) 40%, transparent);
}
/* Loading spinner */
.loading-spinner {
width: 3rem;
height: 3rem;
border-radius: 9999px;
border: 3px solid color-mix(in srgb, var(--cata-primary) 20%, transparent);
border-top-color: var(--cata-primary);
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.dark .loading-spinner {
box-shadow: 0 0 20px color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
/* Responsive */
@media (max-width: 640px) {
.sesion-header {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.stats-bar {
flex-wrap: wrap;
}
.floating-action {
bottom: 1rem;
right: 1rem;
}
.floating-action button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
/* Landscape mobile */
@media (max-height: 500px) and (orientation: landscape) {
.sesion-header {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.stats-bar {
display: none;
}
.floating-action {
bottom: 0.5rem;
right: 0.5rem;
}
}
</style>