Files
cataRio/nuxt4/app/pages/cata/sesion.vue
josedario87 9daafc2a3e
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m5s
Feat: Reorganizar controles de sesión y mejorar UX
- Mover botón Finalizar Sesión del flotante al header
- Cambiar botón flotante para colapsar/expandir todos los acordiones
- Agregar opción Finalizar Sesión al menú desplegable móvil
- Implementar función toggleCollapseAll para control de acordiones
- Agregar computed todosColapsados para estado de acordiones
- Usar iconos maximize-2/minimize-2 según estado
- Hacer botón flotante circular para mejor identificación

- Arreglar hover de acordiones para usar color personalizado
- Agregar estilos :deep() para .accordion-trigger:hover
- Usar color-mix con var(--cata-primary) en hover
- Aplicar 5% opacidad en modo claro, 10% en modo oscuro
2025-10-18 03:33:09 -06:00

505 lines
14 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 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 flex items-center justify-center">
<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 flex items-center justify-center hidden sm:flex"
title="Exportar sesión"
@click="exportar"
>
<UIcon name="i-lucide-download" class="w-5 h-5" />
</button>
<button
class="cata-button p-2 flex items-center justify-center hidden sm:flex"
title="Finalizar sesión"
@click="finalizarSesion"
>
<UIcon name="i-lucide-check-circle" class="w-5 h-5" />
</button>
<button
class="cata-button p-2 flex items-center justify-center"
@click="toggleTheme"
:title="isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'"
>
<UIcon :name="isDark ? 'i-lucide-sun' : 'i-lucide-moon'" class="w-5 h-5" />
</button>
<button
class="cata-button p-2 flex items-center justify-center"
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"
@click="finalizarSesion"
>
<UIcon name="i-lucide-check-circle" class="w-4 h-4 inline mr-2" />
Finalizar 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: 'accordion-trigger w-full px-4 py-3 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 class="p-4">
<CataFormularioMuestra
v-if="sesionActiva && obtenerMuestraPorValue(item.value)"
:muestra="obtenerMuestraPorValue(item.value)!"
:tab-activa="tabActiva"
/>
<div v-else class="text-error">
Error: No se pudo cargar la muestra (value: {{ item.value }})
</div>
</div>
</template>
</UAccordion>
</div>
<!-- Botón flotante para colapsar/expandir -->
<div class="floating-action">
<button
class="cata-button p-3 rounded-full shadow-lg"
@click="toggleCollapseAll"
:title="todosColapsados ? 'Expandir todas las muestras' : 'Colapsar todas las muestras'"
>
<UIcon
:name="todosColapsados ? 'i-lucide-maximize-2' : 'i-lucide-minimize-2'"
class="w-6 h-6"
/>
</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()
const colorMode = useColorMode()
// Estado del menú
const mostrarMenu = ref(false)
// Estado del tema
const isDark = computed(() => colorMode.value === 'dark')
// Cambiar tema
const toggleTheme = () => {
colorMode.preference = isDark.value ? 'light' : 'dark'
}
// Definición de tabs
const tabs = [
{
value: 'organoleptica' as TabCatacion,
label: 'Organoléptica',
icon: 'i-lucide-flower-2',
},
{
value: 'descriptiva-afectiva' as TabCatacion,
label: 'Descriptiva/Afectiva',
icon: 'i-lucide-sliders-horizontal',
},
{
value: 'defectos' as TabCatacion,
label: 'Defectos',
icon: 'i-lucide-alert-triangle',
},
{
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, // Pasar referencia directa sin clonar
}))
})
// Obtener muestra por value del accordion
const obtenerMuestraPorValue = (value: string): Muestra | null => {
if (!sesionActiva.value) return null
// Extraer el ID de la muestra del value (formato: "muestra-{id}")
const muestraId = parseInt(value.replace('muestra-', ''))
return sesionActiva.value.muestras.find(m => m.muestraId === muestraId) || null
}
// Verificar si todos los acordiones están colapsados
const todosColapsados = computed(() => {
return accordionAbierto.value.length === 0
})
// Colapsar o expandir todos los acordiones
const toggleCollapseAll = () => {
if (todosColapsados.value) {
// Expandir todos: agregar todos los values
accordionAbierto.value = accordionItems.value.map(item => item.value)
} else {
// Colapsar todos: vaciar el array
accordionAbierto.value = []
}
}
// 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;
}
/* Accordion hover usando color personalizado */
:deep(.accordion-trigger:hover) {
background-color: color-mix(in srgb, var(--cata-primary) 5%, transparent);
}
:deep(.dark .accordion-trigger:hover) {
background-color: color-mix(in srgb, var(--cata-primary) 10%, transparent);
}
/* 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>