Files
cataRio/nuxt4/app/pages/cata/sesion.vue
josedario87 dd57ee1fb3
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m7s
Feat: Agregar vista expandida de ResumenMuestra
Implementación completa de vista detallada para muestras:

Componentes nuevos:
- ResumenMuestraExpandido: Vista no compacta con valores claramente visibles
- ModalResumenExpandido: Modal reutilizable para mostrar vista expandida

Integraciones:
- Calculadora SCAA: Usa vista expandida en paso 3
- Página de sesión:
  * Long press en móvil (500ms) en header del accordion
  * Botón expandir en desktop cerca del título
  * Vibración háptica en móvil al activar

Características:
- Grid responsivo de intensidades afectivas
- Puntajes destacados (Σ y SCAA) con colores según valor
- Visualización clara de penalizaciones
- Diseño adaptativo móvil/desktop
2025-10-19 04:02:13 -06:00

694 lines
20 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>
<img
src="/icon-192x192.png"
alt="RioCata Logo"
class="w-8 h-8 sm:w-10 sm:h-10"
>
<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>
<!-- 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>
<!-- Subcategorías -->
<div v-if="subcategoriasDisponibles.length > 0" class="subcategorias-container border-t">
<div class="container mx-auto px-4">
<div class="flex flex-wrap gap-1 py-1.5">
<button
v-for="subcategoria in subcategoriasDisponibles"
:key="subcategoria.value ?? 'null'"
:class="[
'subcategoria-chip',
{ 'subcategoria-chip-active': subcategoria.value && subcategoriasActivas.includes(subcategoria.value) },
]"
@click="subcategoria.value && toggleSubcategoria(subcategoria.value)"
>
{{ subcategoria.label }}
</button>
<button
v-if="subcategoriasActivas.length > 0"
class="subcategoria-chip subcategoria-chip-clear"
@click="actualizarSubcategorias([])"
>
<UIcon name="i-lucide-x" class="w-3 h-3 inline mr-1" />
Limpiar Todo
</button>
</div>
</div>
</div>
</div>
<!-- Accordions de Muestras -->
<div class="muestras-container container mx-auto px-0 sm:px-4 py-4">
<UAccordion
v-model="accordionAbierto"
:items="accordionItems"
:ui="{
item: 'border-b border-default',
trigger: 'accordion-trigger w-full px-4 py-3 transition-colors',
content: 'border-t border-default',
}"
>
<!-- Header personalizado con ResumenMuestra -->
<template #default="{ item }">
<div
class="resumen-wrapper"
@touchstart="(e) => onTouchStart(e, item.muestra)"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<CataResumenMuestra
:muestra="item.muestra"
:tab-activa="tabActiva"
/>
<!-- Botón para vista expandida (solo desktop) -->
<button
class="boton-expandir hidden sm:flex"
@click.stop="abrirVistaExpandida(item.muestra)"
title="Ver resumen detallado"
>
<UIcon name="i-lucide-expand" class="w-4 h-4" />
</button>
</div>
</template>
<!-- Content con FormularioMuestra -->
<template #content="{ item }">
<div class="p-3">
<CataFormularioMuestra
v-if="sesionActiva && obtenerMuestraPorValue(item.value)"
:muestra="obtenerMuestraPorValue(item.value)!"
:tab-activa="tabActiva"
:subcategorias-activas="subcategoriasActivas"
/>
<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="floating-action-button cata-button p-2 rounded-full flex items-center justify-center"
@click="toggleCollapseAll"
:title="todosColapsados ? 'Abrir primera muestra' : 'Cerrar muestra abierta'"
>
<UIcon
:name="todosColapsados ? 'i-lucide-maximize-2' : 'i-lucide-minimize-2'"
class="w-5 h-5"
/>
</button>
</div>
<!-- Modal de vista expandida -->
<CataModalResumenExpandido
v-if="muestraExpandida"
v-model="mostrarModalExpandido"
:muestra="muestraExpandida"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { TabCatacion, Subcategoria } from '~/composables/useCatacion'
import type { Muestra } from '~/types/catacion'
const {
sesionActiva,
cargando,
error,
tabActiva,
subcategoriasActivas,
accordionAbierto,
actualizarSubcategorias,
exportarSesion,
eliminarSesionActual,
} = 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',
},
]
// Definición de subcategorías estáticas por tab
const subcategoriasEstaticasPorTab = {
'organoleptica': [
{ value: 'fragancia-aroma' as Subcategoria, label: 'Fragancia/Aroma' },
{ value: 'sabor' as Subcategoria, label: 'Sabor' },
{ value: 'sensacion-boca' as Subcategoria, label: 'Sensación en la Boca' },
{ value: 'gustos-predominantes' as Subcategoria, label: 'Gustos Predominantes' },
],
'descriptiva-afectiva': [
{ value: 'descriptiva' as Subcategoria, label: 'Descriptiva' },
{ value: 'afectiva' as Subcategoria, label: 'Afectiva' },
{ value: 'fragancia' as Subcategoria, label: 'Fragancia' },
{ value: 'aroma' as Subcategoria, label: 'Aroma' },
{ value: 'sabor' as Subcategoria, label: 'Sabor' },
{ value: 'sabor-residual' as Subcategoria, label: 'Sabor Residual' },
{ value: 'acidez' as Subcategoria, label: 'Acidez' },
{ value: 'dulzor' as Subcategoria, label: 'Dulzor' },
{ value: 'sensacion-boca' as Subcategoria, label: 'Sensación en la Boca' },
{ value: 'impresion-global' as Subcategoria, label: 'Impresión Global' },
],
'defectos': [],
}
// Subcategorías dinámicas para Impresión Global (basadas en muestras)
const subcategoriasMuestras = computed(() => {
if (!sesionActiva.value) return []
return sesionActiva.value.muestras.map((muestra, index) => ({
value: `muestra-${muestra.muestraId}` as Subcategoria,
label: muestra.nombre || `Muestra ${index + 1}`,
}))
})
// Subcategorías disponibles para la tab activa
const subcategoriasDisponibles = computed(() => {
if (tabActiva.value === 'impresion-global') {
return subcategoriasMuestras.value
}
return subcategoriasEstaticasPorTab[tabActiva.value] || []
})
// Items del accordion
const accordionItems = computed(() => {
if (!sesionActiva.value) return []
let muestrasAMostrar = sesionActiva.value.muestras
// Si estamos en impresion-global y hay filtros activos, filtrar muestras
if (tabActiva.value === 'impresion-global' && subcategoriasActivas.value.length > 0) {
muestrasAMostrar = sesionActiva.value.muestras.filter((muestra) => {
const muestraValue = `muestra-${muestra.muestraId}`
return subcategoriasActivas.value.includes(muestraValue as Subcategoria)
})
}
return muestrasAMostrar.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
})
// Colapsar o expandir el primer accordion
const toggleCollapseAll = () => {
if (todosColapsados.value) {
// Abrir el primero
accordionAbierto.value = accordionItems.value[0]?.value
} else {
// Cerrar todo
accordionAbierto.value = undefined
}
}
// 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
}
// Toggle subcategoría (selección múltiple)
const toggleSubcategoria = (subcategoria: Subcategoria) => {
if (!subcategoria) return
const actuales = [...subcategoriasActivas.value]
const index = actuales.indexOf(subcategoria)
if (index > -1) {
// Ya está seleccionada, removerla
actuales.splice(index, 1)
} else {
// No está seleccionada, agregarla
actuales.push(subcategoria)
}
// Guardar en localStorage y actualizar estado
actualizarSubcategorias(actuales)
}
// Modal de vista expandida
const mostrarModalExpandido = ref(false)
const muestraExpandida = ref<Muestra | null>(null)
// Abrir vista expandida
const abrirVistaExpandida = (muestra: Muestra) => {
muestraExpandida.value = muestra
mostrarModalExpandido.value = true
}
// Long press en móvil
let longPressTimer: NodeJS.Timeout | null = null
const LONG_PRESS_DURATION = 500 // ms
const onTouchStart = (event: TouchEvent, muestra: Muestra) => {
longPressTimer = setTimeout(() => {
// Vibrar si está disponible
if (navigator.vibrate) {
navigator.vibrate(50)
}
abrirVistaExpandida(muestra)
}, LONG_PRESS_DURATION)
}
const onTouchEnd = () => {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
// 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 = () => {
// 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);
}
/* Subcategorías */
.subcategorias-container {
background-color: var(--cata-bg);
border-color: color-mix(in srgb, var(--cata-primary) 20%, transparent);
}
.subcategoria-chip {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
white-space: nowrap;
transition: all 0.2s;
background-color: color-mix(in srgb, var(--cata-primary) 10%, transparent);
color: var(--cata-text);
border: 1px solid color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
.subcategoria-chip:hover {
background-color: color-mix(in srgb, var(--cata-primary) 20%, transparent);
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
}
.subcategoria-chip-active {
background-color: var(--cata-primary);
color: white;
border-color: var(--cata-primary);
font-weight: 600;
}
.subcategoria-chip-active:hover {
background-color: color-mix(in srgb, var(--cata-primary) 90%, black);
}
.subcategoria-chip-clear {
background-color: color-mix(in srgb, var(--cata-primary) 5%, transparent);
border-color: color-mix(in srgb, var(--cata-primary) 20%, transparent);
opacity: 0.7;
}
.subcategoria-chip-clear:hover {
opacity: 1;
background-color: color-mix(in srgb, var(--cata-primary) 15%, transparent);
}
/* 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);
}
/* Wrapper de resumen con botón expandir */
.resumen-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.boton-expandir {
flex-shrink: 0;
padding: 0.5rem;
border-radius: 0.375rem;
background-color: color-mix(in srgb, var(--cata-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--cata-primary) 30%, transparent);
color: var(--cata-primary);
transition: all 0.2s ease;
cursor: pointer;
align-items: center;
justify-content: center;
}
.boton-expandir:hover {
background-color: color-mix(in srgb, var(--cata-primary) 20%, transparent);
transform: scale(1.05);
}
.boton-expandir:active {
transform: scale(0.95);
}
.dark .boton-expandir {
background-color: color-mix(in srgb, var(--cata-primary) 15%, transparent);
}
.dark .boton-expandir:hover {
background-color: color-mix(in srgb, var(--cata-primary) 25%, transparent);
}
/* Floating action button */
.floating-action {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 20;
}
.floating-action-button {
background-color: var(--cata-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dark .floating-action-button {
background-color: var(--cata-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* 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;
}
.floating-action {
bottom: 1rem;
right: 1rem;
}
}
/* Landscape mobile */
@media (max-height: 500px) and (orientation: landscape) {
.sesion-header {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.floating-action {
bottom: 0.5rem;
right: 0.5rem;
}
}
</style>