Feat: Implementar UI completa de RioCata - Sistema de catación de café
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m3s

Agregar sistema completo de catación de café con las siguientes características:

- Tipos TypeScript completos para sesiones, muestras, intensidades y notas
- Composable useIndexedDB para gestión de sesión activa en cliente
- Composable useCatacion con lógica de negocio para actualización de muestras
- Componentes reutilizables:
  * SliderIntensidad: Slider dual para valores descriptivos (1-10) y afectivos (1-15)
  * SelectorFamilia: Selector jerárquico de familias de notas (3 niveles)
  * SelectorTazas: Selector de tazas (1-5) para uniformidad y defectos
  * ResumenMuestra: Header de accordion con progreso y estadísticas
  * FormularioMuestra: Formulario completo con 3 tabs (Fragancia/Aroma, Sabor, Impresión Global)
- Páginas:
  * /cata: Gestión de sesiones (crear nueva o continuar existente)
  * /cata/sesion: Interfaz principal de catación con accordions y tabs
- Tema dual:
  * Modo claro: Fondo blanco, texto negro, outlines azules
  * Modo oscuro: Fondo negro, texto verde terminal, estilo monospace
- Diseño mobile-first responsive con CSS vanilla (sin @apply de Tailwind)
- Configuración PWA con almacenamiento en IndexedDB
This commit is contained in:
2025-10-18 01:39:27 -06:00
parent 801b650891
commit 87fb92d210
12 changed files with 3776 additions and 0 deletions

View File

@@ -1,2 +1,355 @@
@import "tailwindcss";
@import "@nuxt/ui";
@tailwind utilities;
/* ========================================================================== */
/* ESTILOS PERSONALIZADOS PARA RIOCATA */
/* ========================================================================== */
/* -------------------------------------------------------------------------- */
/* MODO CLARO: Estilo papel blanco con outlines azules */
/* -------------------------------------------------------------------------- */
@media (prefers-color-scheme: light) {
:root {
--cata-bg: #ffffff;
--cata-fg: #000000;
--cata-primary: #4682b4; /* Steel Blue */
--cata-primary-light: #87ceeb; /* Sky Blue */
--cata-primary-dark: #00008b; /* Dark Blue */
--cata-border-width: 1px;
--cata-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
}
:root:not(.dark) {
--cata-bg: #ffffff;
--cata-fg: #000000;
--cata-primary: #4682b4;
--cata-primary-light: #87ceeb;
--cata-primary-dark: #00008b;
--cata-border-width: 1px;
--cata-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* -------------------------------------------------------------------------- */
/* MODO OSCURO: Estilo terminal verde antiguo */
/* -------------------------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
:root {
--cata-bg: #000000;
--cata-fg: #00ff00; /* Terminal Green */
--cata-primary: #00ff00; /* Terminal Green */
--cata-primary-light: #00cc00; /* Medium Green */
--cata-primary-dark: #009900; /* Dark Green */
--cata-border-width: 0.75px;
--cata-font-family: 'Courier New', Courier, 'Lucida Console', Monaco, monospace;
}
}
:root.dark {
--cata-bg: #000000;
--cata-fg: #00ff00;
--cata-primary: #00ff00;
--cata-primary-light: #00cc00;
--cata-primary-dark: #009900;
--cata-border-width: 0.75px;
--cata-font-family: 'Courier New', Courier, 'Lucida Console', Monaco, monospace;
}
/* -------------------------------------------------------------------------- */
/* UTILIDADES GLOBALES */
/* -------------------------------------------------------------------------- */
/* Outline containers sin fondo relleno */
.cata-outline-box {
background-color: transparent;
border: var(--cata-border-width) solid var(--cata-primary);
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
}
.dark .cata-outline-box {
border-color: color-mix(in srgb, var(--cata-primary) 70%, transparent);
}
/* Líneas delgadas horizontales */
.cata-divider {
width: 100%;
height: 1px;
background-color: color-mix(in srgb, var(--cata-primary) 30%, transparent);
border: none;
}
.dark .cata-divider {
background-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
}
/* Texto con estilo terminal en modo oscuro */
.cata-text {
color: var(--cata-fg);
font-family: var(--cata-font-family);
}
.dark .cata-text {
text-shadow: 0 0 2px currentColor;
font-weight: 300;
letter-spacing: 0.02em;
}
/* -------------------------------------------------------------------------- */
/* COMPONENTES ESPECÍFICOS */
/* -------------------------------------------------------------------------- */
/* Páginas de catación: fondo completo */
.cata-page {
min-height: 100vh;
background-color: var(--cata-bg);
color: var(--cata-fg);
}
/* Headers de accordions */
.cata-accordion-header {
background-color: transparent;
border: var(--cata-border-width) solid var(--cata-primary);
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
padding: 0.75rem 1rem;
border-radius: 0.375rem;
transition: all 200ms;
}
.cata-accordion-header:hover {
background-color: color-mix(in srgb, var(--cata-primary) 5%, transparent);
}
.dark .cata-accordion-header:hover {
background-color: color-mix(in srgb, var(--cata-primary) 10%, transparent);
}
/* Tabs personalizados */
.cata-tab {
border-bottom: 2px solid transparent;
padding: 0.5rem 1rem;
transition: all 200ms;
color: color-mix(in srgb, var(--cata-fg) 60%, transparent);
}
.cata-tab-active {
border-bottom: 2px solid var(--cata-primary);
border-color: var(--cata-primary);
color: var(--cata-primary);
font-weight: 600;
}
.dark .cata-tab-active {
text-shadow: 0 0 4px currentColor;
}
/* Inputs con outline */
.cata-input {
background-color: transparent;
border: var(--cata-border-width) solid var(--cata-primary);
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
color: var(--cata-fg);
font-family: var(--cata-font-family);
}
.cata-input:focus {
outline: 2px solid var(--cata-primary);
outline-offset: 2px;
}
.dark .cata-input {
font-weight: 300;
}
/* Sliders personalizados */
.cata-slider-track {
background-color: transparent;
border: var(--cata-border-width) solid;
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
}
.cata-slider-thumb {
background-color: white;
border: 2px solid var(--cata-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dark .cata-slider-thumb {
background-color: black;
}
.dark .cata-slider-thumb {
box-shadow: 0 0 6px var(--cata-primary);
}
/* Checkboxes con outline */
.cata-checkbox {
background-color: transparent;
border: var(--cata-border-width) solid var(--cata-primary);
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: all 150ms;
}
.cata-checkbox:hover {
background-color: color-mix(in srgb, var(--cata-primary) 5%, transparent);
}
.cata-checkbox-checked {
border-color: var(--cata-primary) !important;
border-width: 2px;
background-color: color-mix(in srgb, var(--cata-primary) 10%, transparent);
}
.dark .cata-checkbox-checked {
background-color: color-mix(in srgb, var(--cata-primary) 15%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
/* Botones con outline */
.cata-button {
background-color: transparent;
border: var(--cata-border-width) solid var(--cata-primary);
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
padding: 0.5rem 1rem;
border-radius: 0.375rem;
transition: all 200ms;
cursor: pointer;
color: var(--cata-fg);
font-family: var(--cata-font-family);
}
.cata-button:hover {
background-color: color-mix(in srgb, var(--cata-primary) 10%, transparent);
border-width: 2px;
}
.cata-button:active {
transform: scale(0.98);
}
.dark .cata-button:hover {
box-shadow: 0 0 12px color-mix(in srgb, var(--cata-primary) 40%, transparent);
}
/* -------------------------------------------------------------------------- */
/* ANIMACIONES Y TRANSICIONES */
/* -------------------------------------------------------------------------- */
/* Fade in suave para contenido */
@keyframes cata-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cata-fade-in {
animation: cata-fade-in 0.3s ease-out;
}
/* Glow effect para modo oscuro */
@keyframes cata-glow-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.dark .cata-glow {
animation: cata-glow-pulse 2s ease-in-out infinite;
}
/* -------------------------------------------------------------------------- */
/* RESPONSIVE Y MOBILE-FIRST */
/* -------------------------------------------------------------------------- */
/* Optimizaciones para móvil */
@media (max-width: 640px) {
.cata-accordion-header {
padding: 0.5rem 0.75rem;
}
.cata-tab {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.cata-button {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
}
/* Optimizaciones para tablet */
@media (min-width: 641px) and (max-width: 1024px) {
.cata-accordion-header {
padding: 0.75rem 1rem;
}
}
/* Ajustes para orientación landscape en móviles */
@media (max-height: 500px) and (orientation: landscape) {
.cata-page {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.cata-accordion-header {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
}
/* Touch-friendly: Aumentar áreas de toque */
@media (hover: none) and (pointer: coarse) {
.cata-button,
.cata-checkbox,
.cata-tab {
min-height: 44px;
min-width: 44px;
}
}
/* -------------------------------------------------------------------------- */
/* ACCESIBILIDAD */
/* -------------------------------------------------------------------------- */
/* Focus visible mejorado */
*:focus-visible {
outline: 2px solid var(--cata-primary);
outline-offset: 2px;
border-radius: 4px;
}
.dark *:focus-visible {
outline-width: 3px;
box-shadow: 0 0 8px var(--cata-primary);
}
/* Modo de alto contraste */
@media (prefers-contrast: high) {
:root {
--cata-border-width: 2px;
}
.cata-outline-box {
border-width: 2px;
}
.dark .cata-text {
text-shadow: none;
font-weight: 400;
}
}

View File

@@ -0,0 +1,471 @@
<template>
<div class="formulario-muestra p-4 space-y-6">
<!-- Tab 1: Fragancia y Aroma -->
<div v-if="tabActiva === 'fragancia-aroma'" class="tab-content cata-fade-in">
<h4 class="tab-section-title cata-text mb-4">
Fragancia y Aroma
</h4>
<!-- Sliders de Fragancia -->
<div class="form-section">
<h5 class="form-section-title cata-text">Fragancia</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.fragancia.descriptiva"
@update:model-value="(v) => actualizarIntensidad('fragancia', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.fragancia.afectiva"
@update:model-value="(v) => actualizarIntensidad('fragancia', 'afectiva', v)"
/>
</div>
</div>
<!-- Sliders de Aroma -->
<div class="form-section">
<h5 class="form-section-title cata-text">Aroma</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.aroma.descriptiva"
@update:model-value="(v) => actualizarIntensidad('aroma', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.aroma.afectiva"
@update:model-value="(v) => actualizarIntensidad('aroma', 'afectiva', v)"
/>
</div>
</div>
<!-- Selector de Familia de Fragancia/Aroma -->
<div class="form-section">
<CataSelectorFamilia
tipo="fragancia-aroma"
label="Familia de Fragancia y Aroma"
:model-value="muestra.fraganciaAromaNotas"
@update:model-value="actualizarFraganciaAroma"
/>
</div>
</div>
<!-- Tab 2: Sabor -->
<div v-if="tabActiva === 'sabor'" class="tab-content cata-fade-in">
<h4 class="tab-section-title cata-text mb-4">
Sabor y Características
</h4>
<!-- Sliders de Sabor -->
<div class="form-section">
<h5 class="form-section-title cata-text">Sabor</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.sabor.descriptiva"
@update:model-value="(v) => actualizarIntensidad('sabor', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.sabor.afectiva"
@update:model-value="(v) => actualizarIntensidad('sabor', 'afectiva', v)"
/>
</div>
</div>
<!-- Sliders de Sabor Residual -->
<div class="form-section">
<h5 class="form-section-title cata-text">Sabor Residual</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.saborResidual.descriptiva"
@update:model-value="(v) => actualizarIntensidad('saborResidual', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.saborResidual.afectiva"
@update:model-value="(v) => actualizarIntensidad('saborResidual', 'afectiva', v)"
/>
</div>
</div>
<!-- Sliders de Acidez -->
<div class="form-section">
<h5 class="form-section-title cata-text">Acidez</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.acidez.descriptiva"
@update:model-value="(v) => actualizarIntensidad('acidez', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.acidez.afectiva"
@update:model-value="(v) => actualizarIntensidad('acidez', 'afectiva', v)"
/>
</div>
</div>
<!-- Sliders de Dulzor -->
<div class="form-section">
<h5 class="form-section-title cata-text">Dulzor</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.dulzor.descriptiva"
@update:model-value="(v) => actualizarIntensidad('dulzor', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.dulzor.afectiva"
@update:model-value="(v) => actualizarIntensidad('dulzor', 'afectiva', v)"
/>
</div>
</div>
<!-- Sliders de Sensación en Boca -->
<div class="form-section">
<h5 class="form-section-title cata-text">Sensación en la Boca</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.sensacionBoca.descriptiva"
@update:model-value="(v) => actualizarIntensidad('sensacionBoca', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.sensacionBoca.afectiva"
@update:model-value="(v) => actualizarIntensidad('sensacionBoca', 'afectiva', v)"
/>
</div>
</div>
<!-- Selector de Familia de Sabor -->
<div class="form-section">
<CataSelectorFamilia
tipo="sabor"
label="Familia de Sabor"
:model-value="muestra.saborNotas"
@update:model-value="actualizarSabor"
/>
</div>
</div>
<!-- Tab 3: Impresión Global -->
<div v-if="tabActiva === 'impresion-global'" class="tab-content cata-fade-in">
<h4 class="tab-section-title cata-text mb-4">
Impresión Global y Detalles Finales
</h4>
<!-- Sliders de Impresión Global -->
<div class="form-section">
<h5 class="form-section-title cata-text">Impresión Global</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
tipo="descriptiva"
label="Descriptiva"
:model-value="muestra.intensidades.impresionGlobal.descriptiva"
@update:model-value="(v) => actualizarIntensidad('impresionGlobal', 'descriptiva', v)"
/>
<CataSliderIntensidad
tipo="afectiva"
label="Afectiva"
:model-value="muestra.intensidades.impresionGlobal.afectiva"
@update:model-value="(v) => actualizarIntensidad('impresionGlobal', 'afectiva', v)"
/>
</div>
</div>
<!-- Tazas No Uniformes -->
<div class="form-section">
<CataSelectorTazas
tipo="uniformes"
label="Tazas NO Uniformes"
:model-value="muestra.tazasNoUniformes"
@update:model-value="actualizarTazasNoUniformes"
/>
</div>
<!-- Tazas Defectuosas -->
<div class="form-section">
<CataSelectorTazas
tipo="defectuosas"
label="Tazas Defectuosas"
:model-value="muestra.tazasDefectuosas"
@update:model-value="actualizarTazasDefectuosas"
/>
</div>
<!-- Tipo de Defecto -->
<div v-if="muestra.tazasDefectuosas.length > 0" class="form-section">
<label class="block text-sm font-medium mb-2 cata-text">
Tipo de Defecto
</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<button
v-for="tipo in tiposDefectos"
:key="tipo || 'ninguno'"
type="button"
:class="[
'cata-checkbox',
{ 'cata-checkbox-checked': muestra.defecto === tipo },
]"
@click="actualizarDefecto(tipo)"
>
<span class="cata-text">{{ tipo || 'Ninguno' }}</span>
</button>
</div>
</div>
<!-- Sensaciones en Boca (selección múltiple) -->
<div class="form-section">
<label class="block text-sm font-medium mb-2 cata-text">
Sensaciones en la Boca (múltiples)
</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button
v-for="sensacion in sensacionesBoca"
:key="sensacion"
type="button"
:class="[
'cata-checkbox',
{ 'cata-checkbox-checked': muestra.sensacionEnBoca.includes(sensacion) },
]"
@click="toggleSensacionBoca(sensacion)"
>
<span class="cata-text text-sm">{{ sensacion }}</span>
</button>
</div>
</div>
<!-- Gustos Predominantes (máx 2) -->
<div class="form-section">
<label class="block text-sm font-medium mb-2 cata-text">
Gustos Predominantes (mín 1, máx 2)
</label>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
<button
v-for="gusto in gustosPredominantes"
:key="gusto"
type="button"
:class="[
'cata-checkbox',
{ 'cata-checkbox-checked': muestra.gustosPredominantes.includes(gusto) },
]"
:disabled="!muestra.gustosPredominantes.includes(gusto) && muestra.gustosPredominantes.length >= 2"
@click="toggleGustoPredominante(gusto)"
>
<span class="cata-text">{{ gusto }}</span>
</button>
</div>
</div>
<!-- Otras Notas -->
<div class="form-section">
<label class="block text-sm font-medium mb-2 cata-text">
Otras Notas
</label>
<textarea
v-model="otrasNotasLocal"
class="cata-input w-full min-h-[100px] resize-y"
placeholder="Notas adicionales sobre el café, cuerpo, balance, etc..."
@blur="actualizarOtrasNotas"
/>
</div>
<!-- Puntaje Final (solo lectura) -->
<div class="form-section">
<div class="puntaje-final cata-outline-box p-4 rounded-md">
<div class="flex items-baseline justify-between">
<span class="text-sm cata-text opacity-75">Puntaje Final:</span>
<span class="text-3xl font-bold cata-text">{{ muestra.puntajeFinal }}</span>
</div>
<p class="text-xs cata-text opacity-60 mt-1">
Suma de valores afectivos
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Muestra, NotaSeleccionada, TipoDefecto, SensacionBoca, GustoPredominante } from '~/types/catacion'
import type { TabCatacion } from '~/composables/useCatacion'
import { SENSACIONES_BOCA, GUSTOS_PREDOMINANTES, TIPOS_DEFECTOS } from '~/types/catacion'
interface FormularioMuestraProps {
/** Muestra a editar */
muestra: Muestra
/** Tab activa */
tabActiva: TabCatacion
}
const props = defineProps<FormularioMuestraProps>()
const { actualizarIntensidad: actualizarIntensidadCatacion } = useCatacion()
// Listas para los selectores
const sensacionesBoca = SENSACIONES_BOCA
const gustosPredominantes = GUSTOS_PREDOMINANTES
const tiposDefectos = TIPOS_DEFECTOS
// Estado local para otras notas
const otrasNotasLocal = ref(props.muestra.otrasNotas)
// Actualizar intensidad
const actualizarIntensidad = async (
parametro: keyof Muestra['intensidades'],
tipo: 'descriptiva' | 'afectiva',
valor: number | null
) => {
await actualizarIntensidadCatacion(props.muestra.muestraId, parametro, tipo, valor)
}
// Actualizar fragancia/aroma
const { actualizarFraganciaAroma: actualizarFraganciaAromaCatacion } = useCatacion()
const actualizarFraganciaAroma = async (nota: NotaSeleccionada) => {
await actualizarFraganciaAromaCatacion(
props.muestra.muestraId,
nota.categoria,
nota.subcategoria,
nota.notaEspecifica
)
}
// Actualizar sabor
const { actualizarSabor: actualizarSaborCatacion } = useCatacion()
const actualizarSabor = async (nota: NotaSeleccionada) => {
await actualizarSaborCatacion(
props.muestra.muestraId,
nota.categoria,
nota.subcategoria,
nota.notaEspecifica
)
}
// Actualizar tazas
const { actualizarTazasNoUniformes: actualizarTazasNoUniformesCatacion, actualizarTazasDefectuosas: actualizarTazasDefectuosasCatacion } = useCatacion()
const actualizarTazasNoUniformes = async (tazas: number[]) => {
await actualizarTazasNoUniformesCatacion(props.muestra.muestraId, tazas)
}
const actualizarTazasDefectuosas = async (tazas: number[]) => {
await actualizarTazasDefectuosasCatacion(props.muestra.muestraId, tazas)
}
// Actualizar defecto
const { actualizarDefecto: actualizarDefectoCatacion } = useCatacion()
const actualizarDefecto = async (defecto: TipoDefecto) => {
await actualizarDefectoCatacion(props.muestra.muestraId, defecto)
}
// Toggle sensación en boca
const { actualizarSensacionBoca } = useCatacion()
const toggleSensacionBoca = async (sensacion: SensacionBoca) => {
const sensaciones = [...props.muestra.sensacionEnBoca]
const index = sensaciones.indexOf(sensacion)
if (index > -1) {
sensaciones.splice(index, 1)
} else {
sensaciones.push(sensacion)
}
await actualizarSensacionBoca(props.muestra.muestraId, sensaciones)
}
// Toggle gusto predominante
const { actualizarGustosPredominantes } = useCatacion()
const toggleGustoPredominante = async (gusto: GustoPredominante) => {
const gustos = [...props.muestra.gustosPredominantes]
const index = gustos.indexOf(gusto)
if (index > -1) {
gustos.splice(index, 1)
} else {
if (gustos.length >= 2) return // Máximo 2
gustos.push(gusto)
}
if (gustos.length === 0) return // Mínimo 1
await actualizarGustosPredominantes(props.muestra.muestraId, gustos)
}
// Actualizar otras notas
const { actualizarOtrasNotas: actualizarOtrasNotasCatacion } = useCatacion()
const actualizarOtrasNotas = async () => {
const notas = otrasNotasLocal.value.trim()
await actualizarOtrasNotasCatacion(props.muestra.muestraId, notas)
}
// Sincronizar otras notas cuando cambia la muestra
watch(() => props.muestra.otrasNotas, (newVal) => {
if (newVal !== otrasNotasLocal.value) {
otrasNotasLocal.value = newVal
}
})
</script>
<style scoped>
.formulario-muestra {
width: 100%;
}
.tab-section-title {
font-size: 1.125rem;
font-weight: 600;
border-bottom: 1px solid;
padding-bottom: 0.5rem;
border-color: color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
.form-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-section-title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.75;
}
.puntaje-final {
text-align: center;
}
/* Responsive */
@media (max-width: 768px) {
.formulario-muestra {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.tab-section-title {
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div class="resumen-muestra cata-accordion-header">
<!-- Header con nombre y puntaje -->
<div class="resumen-header">
<div class="resumen-title">
<h3 class="muestra-nombre cata-text">{{ muestra.nombre }}</h3>
<span class="muestra-id cata-text opacity-60">#{{ muestra.muestraId }}</span>
</div>
<div class="resumen-score">
<span class="score-label cata-text text-xs opacity-60">Puntaje</span>
<span class="score-value cata-text font-bold text-lg">{{ muestra.puntajeFinal }}</span>
</div>
</div>
<!-- Barra de progreso de completitud -->
<div class="resumen-progress mt-2">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${porcentajeCompletitud}%` }"
/>
</div>
<span class="progress-text cata-text text-xs opacity-75">
{{ porcentajeCompletitud }}% completado
</span>
</div>
<!-- Indicadores rápidos -->
<div class="resumen-indicadores mt-3">
<!-- Fragancia/Aroma -->
<div v-if="muestra.fraganciaAromaNotas.categoria" class="indicador">
<UIcon name="i-lucide-flower-2" class="indicador-icon" />
<span class="indicador-text cata-text">
{{ muestra.fraganciaAromaNotas.notaEspecifica || muestra.fraganciaAromaNotas.categoria }}
</span>
</div>
<!-- Sabor -->
<div v-if="muestra.saborNotas.categoria" class="indicador">
<UIcon name="i-lucide-coffee" class="indicador-icon" />
<span class="indicador-text cata-text">
{{ muestra.saborNotas.notaEspecifica || muestra.saborNotas.categoria }}
</span>
</div>
<!-- Defectos -->
<div v-if="muestra.defecto || muestra.tazasDefectuosas.length > 0" class="indicador defecto">
<UIcon name="i-lucide-alert-triangle" class="indicador-icon" />
<span class="indicador-text cata-text">
{{ muestra.defecto || `${muestra.tazasDefectuosas.length} defectuosa(s)` }}
</span>
</div>
<!-- Gustos predominantes -->
<div v-if="muestra.gustosPredominantes.length > 0" class="indicador">
<UIcon name="i-lucide-sparkles" class="indicador-icon" />
<span class="indicador-text cata-text">
{{ muestra.gustosPredominantes.join(', ') }}
</span>
</div>
</div>
<!-- Valores clave de intensidad (versión compacta) -->
<div v-if="mostrarIntensidades" class="resumen-intensidades mt-3">
<div class="intensidad-compact">
<span class="intensidad-label cata-text">F:</span>
<span class="intensidad-value cata-text">
{{ formatearIntensidad(muestra.intensidades.fragancia) }}
</span>
</div>
<div class="intensidad-compact">
<span class="intensidad-label cata-text">A:</span>
<span class="intensidad-value cata-text">
{{ formatearIntensidad(muestra.intensidades.aroma) }}
</span>
</div>
<div class="intensidad-compact">
<span class="intensidad-label cata-text">S:</span>
<span class="intensidad-value cata-text">
{{ formatearIntensidad(muestra.intensidades.sabor) }}
</span>
</div>
<div class="intensidad-compact">
<span class="intensidad-label cata-text">Ac:</span>
<span class="intensidad-value cata-text">
{{ formatearIntensidad(muestra.intensidades.acidez) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Muestra, IntensidadValor } from '~/types/catacion'
interface ResumenMuestraProps {
/** Muestra a mostrar */
muestra: Muestra
/** Porcentaje de completitud */
porcentajeCompletitud: number
/** Mostrar intensidades en el resumen */
mostrarIntensidades?: boolean
}
const props = withDefaults(defineProps<ResumenMuestraProps>(), {
mostrarIntensidades: true,
})
/**
* Formatea una intensidad para mostrar de forma compacta
* Formato: D/A (Descriptiva/Afectiva)
*/
const formatearIntensidad = (intensidad: IntensidadValor): string => {
const desc = intensidad.descriptiva ?? '-'
const afec = intensidad.afectiva ?? '-'
return `${desc}/${afec}`
}
</script>
<style scoped>
.resumen-muestra {
width: 100%;
cursor: pointer;
user-select: none;
}
.resumen-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.resumen-title {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex: 1 1 0%;
min-width: 0;
}
.muestra-nombre {
font-size: 1rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.muestra-id {
font-size: 0.75rem;
flex-shrink: 0;
}
.resumen-score {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.score-label {
line-height: 1.25;
}
.score-value {
line-height: 1.25;
}
/* Barra de progreso */
.resumen-progress {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-bar {
flex: 1 1 0%;
height: 0.375rem;
border-radius: 9999px;
background-color: transparent;
border: var(--cata-border-width) solid;
border-color: color-mix(in srgb, var(--cata-primary) 30%, transparent);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 9999px;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
background-color: var(--cata-primary);
opacity: 0.6;
}
.dark .progress-fill {
opacity: 0.8;
box-shadow: 0 0 4px var(--cata-primary);
}
.progress-text {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* Indicadores */
.resumen-indicadores {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.indicador {
display: flex;
align-items: center;
gap: 0.25rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
background-color: color-mix(in srgb, var(--cata-primary) 5%, transparent);
}
.indicador.defecto {
background-color: color-mix(in srgb, #ef4444 10%, transparent);
}
.indicador-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.indicador-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
/* Intensidades compactas */
.resumen-intensidades {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.75rem;
}
.intensidad-compact {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.intensidad-label {
font-weight: 600;
opacity: 0.75;
}
.intensidad-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
/* Responsive */
@media (max-width: 640px) {
.muestra-nombre {
font-size: 0.875rem;
}
.score-value {
font-size: 1rem;
}
.resumen-indicadores {
gap: 0.375rem;
}
.indicador {
padding-left: 0.375rem;
padding-right: 0.375rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.indicador-icon {
width: 0.75rem;
height: 0.75rem;
}
.indicador-text {
max-width: 100px;
}
.resumen-intensidades {
gap: 0.5rem;
font-size: 0.625rem;
}
}
@media (min-width: 641px) {
.muestra-nombre {
font-size: 1.125rem;
}
}
</style>

View File

@@ -0,0 +1,374 @@
<template>
<div class="selector-familia cata-fade-in">
<!-- Label principal -->
<label v-if="label" class="block text-sm font-medium mb-3 cata-text">
{{ label }}
<span v-if="required" class="text-error">*</span>
</label>
<!-- Nivel 1: Categorías principales -->
<div class="nivel-container">
<h4 class="nivel-title cata-text">Categoría</h4>
<div class="categorias-grid">
<button
v-for="categoria in categoriasDisponibles"
:key="categoria"
type="button"
:class="[
'categoria-item',
'cata-checkbox',
{
'cata-checkbox-checked': modelValue.categoria === categoria,
'disabled': disabled,
},
]"
:disabled="disabled"
@click="seleccionarCategoria(categoria)"
>
<span class="categoria-text cata-text">{{ categoria }}</span>
<UIcon
v-if="modelValue.categoria === categoria"
name="i-lucide-check-circle"
class="categoria-check"
/>
</button>
</div>
</div>
<!-- Nivel 2: Subcategorías (si aplica) -->
<div v-if="subcategoriasDisponibles.length > 0" class="nivel-container mt-4">
<h4 class="nivel-title cata-text">Subcategoría</h4>
<div class="subcategorias-grid">
<button
v-for="subcategoria in subcategoriasDisponibles"
:key="subcategoria"
type="button"
:class="[
'subcategoria-item',
'cata-checkbox',
{
'cata-checkbox-checked': modelValue.subcategoria === subcategoria,
'disabled': disabled,
},
]"
:disabled="disabled"
@click="seleccionarSubcategoria(subcategoria)"
>
<span class="subcategoria-text cata-text">{{ subcategoria }}</span>
<UIcon
v-if="modelValue.subcategoria === subcategoria"
name="i-lucide-check"
class="subcategoria-check"
/>
</button>
</div>
</div>
<!-- Nivel 3: Nota específica (input libre) -->
<div v-if="modelValue.categoria" class="nivel-container mt-4">
<h4 class="nivel-title cata-text">Nota Específica</h4>
<input
v-model="notaEspecificaLocal"
type="text"
:placeholder="notaPlaceholder"
:disabled="disabled"
class="cata-input w-full"
@blur="actualizarNotaEspecifica"
>
</div>
<!-- Resumen de selección -->
<div v-if="seleccionCompleta" class="mt-4 p-3 cata-outline-box rounded-md">
<p class="text-xs font-semibold cata-text mb-1">Selección actual:</p>
<p class="text-sm cata-text">
{{ modelValue.categoria }}
<template v-if="modelValue.subcategoria">
{{ modelValue.subcategoria }}
</template>
<template v-if="modelValue.notaEspecifica">
<span class="font-semibold">{{ modelValue.notaEspecifica }}</span>
</template>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { NotaSeleccionada, CategoriaNotaPrincipal } from '~/types/catacion'
import { FAMILIAS_NOTAS_ESTRUCTURA } from '~/types/catacion'
interface SelectorFamiliaProps {
/** Tipo de selector: fragancia-aroma o sabor */
tipo: 'fragancia-aroma' | 'sabor'
/** Nota seleccionada */
modelValue: NotaSeleccionada
/** Etiqueta del selector */
label?: string
/** Deshabilitar el selector */
disabled?: boolean
/** Marcar como requerido */
required?: boolean
}
const props = withDefaults(defineProps<SelectorFamiliaProps>(), {
disabled: false,
required: false,
})
const emit = defineEmits<{
'update:modelValue': [value: NotaSeleccionada]
}>()
// Estado local para la nota específica
const notaEspecificaLocal = ref(props.modelValue.notaEspecifica || '')
// Placeholder para el input de nota específica
const notaPlaceholder = computed(() => {
return props.tipo === 'fragancia-aroma'
? 'Ej: Naranja, Jazmín, Caramelo...'
: 'Ej: Fresa, Chocolate, Canela...'
})
// Categorías principales disponibles
const categoriasDisponibles = computed<CategoriaNotaPrincipal[]>(() => {
return Object.keys(FAMILIAS_NOTAS_ESTRUCTURA) as CategoriaNotaPrincipal[]
})
// Subcategorías disponibles según la categoría seleccionada
const subcategoriasDisponibles = computed<string[]>(() => {
if (!props.modelValue.categoria) return []
const familia = FAMILIAS_NOTAS_ESTRUCTURA[props.modelValue.categoria as CategoriaNotaPrincipal]
if (!familia || typeof familia !== 'object') return []
return Object.keys(familia)
})
// Verifica si la selección está completa
const seleccionCompleta = computed(() => {
return props.modelValue.categoria !== null
})
// Seleccionar categoría
const seleccionarCategoria = (categoria: CategoriaNotaPrincipal) => {
if (props.disabled) return
// Si se selecciona la misma categoría, deseleccionar todo
if (props.modelValue.categoria === categoria) {
emit('update:modelValue', {
categoria: null,
subcategoria: null,
notaEspecifica: null,
})
notaEspecificaLocal.value = ''
return
}
// Seleccionar nueva categoría y resetear subcategoría y nota
emit('update:modelValue', {
categoria,
subcategoria: null,
notaEspecifica: null,
})
notaEspecificaLocal.value = ''
}
// Seleccionar subcategoría
const seleccionarSubcategoria = (subcategoria: string) => {
if (props.disabled) return
// Si se selecciona la misma subcategoría, deseleccionar
if (props.modelValue.subcategoria === subcategoria) {
emit('update:modelValue', {
...props.modelValue,
subcategoria: null,
})
return
}
// Seleccionar nueva subcategoría
emit('update:modelValue', {
...props.modelValue,
subcategoria,
})
}
// Actualizar nota específica
const actualizarNotaEspecifica = () => {
const nota = notaEspecificaLocal.value.trim()
emit('update:modelValue', {
...props.modelValue,
notaEspecifica: nota || null,
})
}
// Sincronizar nota específica cuando cambia el modelo
watch(() => props.modelValue.notaEspecifica, (newVal) => {
if (newVal !== notaEspecificaLocal.value) {
notaEspecificaLocal.value = newVal || ''
}
})
</script>
<style scoped>
.selector-familia {
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.nivel-container {
width: 100%;
}
.nivel-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
opacity: 0.75;
}
/* Grid de categorías */
.categorias-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.categoria-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 50px;
padding: 0.75rem;
}
.categoria-item:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.categoria-item:focus-visible {
box-shadow: 0 0 0 2px var(--cata-primary);
}
.categoria-text {
font-size: 0.875rem;
font-weight: 500;
flex: 1 1 0%;
text-align: left;
}
.categoria-check {
width: 1.25rem;
height: 1.25rem;
color: var(--cata-primary);
flex-shrink: 0;
margin-left: 0.5rem;
}
/* Grid de subcategorías */
.subcategorias-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.subcategoria-item {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
min-height: 44px;
}
.subcategoria-item:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.subcategoria-item:focus-visible {
box-shadow: 0 0 0 2px var(--cata-primary);
}
.subcategoria-text {
font-size: 0.875rem;
}
.subcategoria-check {
width: 1rem;
height: 1rem;
color: var(--cata-primary);
}
/* Animaciones */
.categoria-item.cata-checkbox-checked,
.subcategoria-item.cata-checkbox-checked {
transform: scale(1.02);
}
.categoria-item:not(.disabled):hover,
.subcategoria-item:not(.disabled):hover {
transform: scale(1.02);
}
.categoria-item:not(.disabled):active,
.subcategoria-item:not(.disabled):active {
transform: scale(0.98);
}
/* Responsive */
@media (max-width: 640px) {
.categorias-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0.375rem;
}
.categoria-item {
min-height: 48px;
padding: 0.625rem;
}
.categoria-text {
font-size: 0.75rem;
}
.subcategoria-item {
min-height: 42px;
padding-left: 0.625rem;
padding-right: 0.625rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.subcategoria-text {
font-size: 0.75rem;
}
}
@media (min-width: 641px) {
.categorias-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.categoria-item {
min-height: 55px;
}
}
/* Touch-friendly */
@media (hover: none) and (pointer: coarse) {
.categoria-item,
.subcategoria-item {
min-height: 50px;
}
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div class="selector-tazas cata-fade-in">
<!-- Label -->
<label v-if="label" class="block text-sm font-medium mb-3 cata-text">
{{ label }}
<span v-if="required" class="text-error">*</span>
</label>
<!-- Grid de tazas -->
<div class="tazas-grid">
<button
v-for="numeroTaza in [1, 2, 3, 4, 5]"
:key="numeroTaza"
type="button"
:class="[
'taza-item',
'cata-checkbox',
{
'cata-checkbox-checked': esTazaSeleccionada(numeroTaza),
'disabled': disabled,
},
]"
:disabled="disabled"
@click="toggleTaza(numeroTaza)"
>
<!-- Número de taza -->
<span class="taza-numero cata-text">{{ numeroTaza }}</span>
<!-- Indicador de selección -->
<div
v-if="esTazaSeleccionada(numeroTaza)"
class="taza-check"
>
<UIcon name="i-lucide-check" class="w-4 h-4" />
</div>
</button>
</div>
<!-- Descripción -->
<p v-if="showDescription" class="text-xs mt-2 cata-text opacity-75">
{{ tipoDescription }}
</p>
<!-- Selección actual -->
<p v-if="modelValue.length > 0" class="text-xs mt-2 cata-text">
Seleccionadas: {{ modelValue.join(', ') }}
</p>
</div>
</template>
<script setup lang="ts">
interface SelectorTazasProps {
/** Tipo de selector */
tipo: 'uniformes' | 'defectuosas'
/** Tazas seleccionadas (números 1-5) */
modelValue: number[]
/** Etiqueta del selector */
label?: string
/** Deshabilitar el selector */
disabled?: boolean
/** Marcar como requerido */
required?: boolean
/** Mostrar descripción del tipo */
showDescription?: boolean
}
const props = withDefaults(defineProps<SelectorTazasProps>(), {
disabled: false,
required: false,
showDescription: true,
})
const emit = defineEmits<{
'update:modelValue': [value: number[]]
}>()
// Descripción según el tipo
const tipoDescription = computed(() => {
return props.tipo === 'uniformes'
? 'Selecciona las tazas que NO son uniformes'
: 'Selecciona las tazas que presentan defectos'
})
// Verifica si una taza está seleccionada
const esTazaSeleccionada = (numeroTaza: number): boolean => {
return props.modelValue.includes(numeroTaza)
}
// Toggle de selección de taza
const toggleTaza = (numeroTaza: number) => {
if (props.disabled) return
const seleccionadas = [...props.modelValue]
const index = seleccionadas.indexOf(numeroTaza)
if (index > -1) {
// Deseleccionar
seleccionadas.splice(index, 1)
} else {
// Seleccionar
seleccionadas.push(numeroTaza)
}
// Ordenar las tazas seleccionadas
seleccionadas.sort((a, b) => a - b)
emit('update:modelValue', seleccionadas)
}
</script>
<style scoped>
.selector-tazas {
width: 100%;
}
.tazas-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
}
.taza-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60px;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.taza-item:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.taza-item:focus-visible {
box-shadow: 0 0 0 2px var(--cata-primary);
}
.taza-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.taza-numero {
font-size: 1.125rem;
font-weight: 600;
}
.taza-check {
position: absolute;
top: 0.25rem;
right: 0.25rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--cata-primary);
color: white;
}
.dark .taza-check {
color: black;
}
/* Animación al seleccionar */
.taza-item.cata-checkbox-checked {
transform: scale(1.05);
}
/* Hover effect */
.taza-item:not(.disabled):hover {
transform: scale(1.02);
}
/* Active effect */
.taza-item:not(.disabled):active {
transform: scale(0.98);
}
/* Responsive */
@media (max-width: 640px) {
.tazas-grid {
gap: 0.375rem;
}
.taza-item {
min-height: 50px;
}
.taza-numero {
font-size: 1rem;
}
.taza-check {
width: 1rem;
height: 1rem;
}
.taza-check :deep(svg) {
width: 0.75rem;
height: 0.75rem;
}
}
@media (min-width: 641px) {
.taza-item {
min-height: 70px;
}
.taza-numero {
font-size: 1.25rem;
}
}
/* Touch-friendly */
@media (hover: none) and (pointer: coarse) {
.taza-item {
min-height: 60px;
}
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="slider-intensidad cata-fade-in">
<!-- Label -->
<label
v-if="label"
:for="inputId"
class="block text-sm font-medium mb-2 cata-text"
>
{{ label }}
<span v-if="required" class="text-error">*</span>
</label>
<!-- Slider Container -->
<div class="relative">
<USlider
:id="inputId"
:model-value="modelValue ?? undefined"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
:tooltip="tooltipConfig"
:ui="sliderUi"
@update:model-value="handleChange"
/>
<!-- Indicadores de rango -->
<div class="flex justify-between mt-2 text-xs cata-text opacity-60">
<span>{{ min }}</span>
<span v-if="modelValue !== null" class="font-semibold opacity-100">
{{ modelValue }}
</span>
<span>{{ max }}</span>
</div>
</div>
<!-- Descripción del tipo -->
<p v-if="showDescription" class="text-xs mt-1 cata-text opacity-75">
{{ tipoDescription }}
</p>
</div>
</template>
<script setup lang="ts">
import type { TooltipProps } from '@nuxt/ui'
interface SliderIntensidadProps {
/** Tipo de intensidad: descriptiva (1-10) o afectiva (1-15) */
tipo: 'descriptiva' | 'afectiva'
/** Valor actual del slider */
modelValue: number | null
/** Etiqueta del slider */
label?: string
/** ID para el input (auto-generado si no se provee) */
id?: string
/** Deshabilitar el slider */
disabled?: boolean
/** Marcar como requerido */
required?: boolean
/** Mostrar descripción del tipo */
showDescription?: boolean
}
const props = withDefaults(defineProps<SliderIntensidadProps>(), {
disabled: false,
required: false,
showDescription: true,
})
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
// ID único para el input
const inputId = computed(() => props.id || `slider-${Math.random().toString(36).substring(2, 9)}`)
// Configuración según el tipo
const min = computed(() => 1)
const max = computed(() => props.tipo === 'descriptiva' ? 10 : 15)
const step = computed(() => 1)
// Descripción del tipo de intensidad
const tipoDescription = computed(() => {
return props.tipo === 'descriptiva'
? 'Intensidad descriptiva: qué tan intensa es la característica (sin importar si es buena o mala)'
: 'Intensidad afectiva: qué tan buena o mala consideras esta característica'
})
// Configuración del tooltip
const tooltipConfig = computed<boolean | TooltipProps>(() => ({
disableClosingTrigger: true,
text: props.modelValue !== null ? String(props.modelValue) : '',
}))
// Configuración UI personalizada del slider
const sliderUi = computed(() => ({
root: 'cata-slider-root',
track: 'cata-slider-track',
range: props.tipo === 'descriptiva'
? 'bg-primary/20 dark:bg-primary/30'
: 'bg-primary/40 dark:bg-primary/50',
thumb: 'cata-slider-thumb',
}))
// Manejar cambio de valor
const handleChange = (value: number | number[] | undefined) => {
if (value === undefined || value === null) {
emit('update:modelValue', null)
return
}
const newValue = Array.isArray(value) ? (value[0] ?? null) : value
emit('update:modelValue', newValue)
}
</script>
<style scoped>
.slider-intensidad {
width: 100%;
}
/* Personalización adicional para el slider */
:deep(.cata-slider-root) {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
:deep(.cata-slider-track) {
height: 0.5rem;
border-radius: 9999px;
background: transparent;
border: var(--cata-border-width) solid;
border-color: color-mix(in srgb, var(--cata-primary) 50%, transparent);
}
:deep(.cata-slider-thumb) {
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
background-color: var(--cata-bg);
border: 2px solid var(--cata-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
:deep(.cata-slider-thumb:hover) {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
}
:deep(.cata-slider-thumb:active) {
transform: scale(1.05);
}
.dark :deep(.cata-slider-thumb) {
box-shadow: 0 0 6px var(--cata-primary);
}
.dark :deep(.cata-slider-thumb:hover) {
box-shadow: 0 0 12px var(--cata-primary);
}
/* Animación de fade in */
.slider-intensidad.cata-fade-in {
animation: cata-fade-in 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,346 @@
/**
* Composable para manejar la lógica de negocio de catación
*/
import type { SesionCatacion, Muestra, IntensidadValor } from '~/types/catacion'
import { crearSesionVacia, calcularPuntajeFinal } from '~/types/catacion'
export type TabCatacion = 'fragancia-aroma' | 'sabor' | 'impresion-global'
export const useCatacion = () => {
const { sesionActiva, cargando, error, guardar, actualizar, eliminar } = useIndexedDB()
// Estado de la UI
const tabActiva = useState<TabCatacion>('tab-activa', () => 'fragancia-aroma')
const accordionAbierto = useState<string[]>('accordion-abierto', () => [])
/**
* Crea una nueva sesión de catación
*/
const crearNuevaSesion = async (catador: string, cantidadMuestras: number) => {
try {
const nuevaSesion = crearSesionVacia(catador, cantidadMuestras)
await guardar(nuevaSesion)
// Resetear estado de UI
tabActiva.value = 'fragancia-aroma'
accordionAbierto.value = []
return nuevaSesion
} catch (err) {
console.error('Error al crear nueva sesión:', err)
throw err
}
}
/**
* Actualiza una muestra específica y guarda en IndexedDB
*/
const actualizarMuestra = async (muestraId: number, muestraActualizada: Partial<Muestra>) => {
if (!sesionActiva.value) {
throw new Error('No hay sesión activa')
}
try {
const sesionClonada = JSON.parse(JSON.stringify(sesionActiva.value)) as SesionCatacion
const indexMuestra = sesionClonada.muestras.findIndex(m => m.muestraId === muestraId)
if (indexMuestra === -1) {
throw new Error(`Muestra con ID ${muestraId} no encontrada`)
}
// Actualizar muestra
const muestraActual = sesionClonada.muestras[indexMuestra]
if (!muestraActual) {
throw new Error(`Muestra con ID ${muestraId} no encontrada`)
}
sesionClonada.muestras[indexMuestra] = {
...muestraActual,
...muestraActualizada,
} as Muestra
// Recalcular puntaje final si hay cambios en intensidades
if (muestraActualizada.intensidades) {
const muestraFinal = sesionClonada.muestras[indexMuestra]
if (muestraFinal) {
sesionClonada.muestras[indexMuestra]!.puntajeFinal = calcularPuntajeFinal(muestraFinal)
}
}
await actualizar(sesionClonada)
} catch (err) {
console.error('Error al actualizar muestra:', err)
throw err
}
}
/**
* Actualiza un valor de intensidad específico
*/
const actualizarIntensidad = async (
muestraId: number,
parametro: keyof Muestra['intensidades'],
tipo: 'descriptiva' | 'afectiva',
valor: number | null
) => {
if (!sesionActiva.value) {
throw new Error('No hay sesión activa')
}
try {
const sesionClonada = JSON.parse(JSON.stringify(sesionActiva.value)) as SesionCatacion
const indexMuestra = sesionClonada.muestras.findIndex(m => m.muestraId === muestraId)
if (indexMuestra === -1) {
throw new Error(`Muestra con ID ${muestraId} no encontrada`)
}
// Actualizar valor de intensidad
const muestraActual = sesionClonada.muestras[indexMuestra]
if (!muestraActual) {
throw new Error(`Muestra con ID ${muestraId} no encontrada`)
}
const intensidadActual = muestraActual.intensidades[parametro]
sesionClonada.muestras[indexMuestra]!.intensidades[parametro] = {
...intensidadActual,
[tipo]: valor,
}
// Recalcular puntaje final
const muestraFinal = sesionClonada.muestras[indexMuestra]
if (muestraFinal) {
sesionClonada.muestras[indexMuestra]!.puntajeFinal = calcularPuntajeFinal(muestraFinal)
}
await actualizar(sesionClonada)
} catch (err) {
console.error('Error al actualizar intensidad:', err)
throw err
}
}
/**
* Actualiza el nombre de una muestra
*/
const actualizarNombreMuestra = async (muestraId: number, nombre: string) => {
await actualizarMuestra(muestraId, { nombre })
}
/**
* Actualiza las notas de fragancia/aroma
*/
const actualizarFraganciaAroma = async (
muestraId: number,
categoria: string | null,
subcategoria: string | null,
notaEspecifica: string | null
) => {
await actualizarMuestra(muestraId, {
fraganciaAromaNotas: { categoria: categoria as any, subcategoria, notaEspecifica },
})
}
/**
* Actualiza las notas de sabor
*/
const actualizarSabor = async (
muestraId: number,
categoria: string | null,
subcategoria: string | null,
notaEspecifica: string | null
) => {
await actualizarMuestra(muestraId, {
saborNotas: { categoria: categoria as any, subcategoria, notaEspecifica },
})
}
/**
* Actualiza tazas no uniformes
*/
const actualizarTazasNoUniformes = async (muestraId: number, tazas: number[]) => {
await actualizarMuestra(muestraId, { tazasNoUniformes: tazas })
}
/**
* Actualiza tazas defectuosas
*/
const actualizarTazasDefectuosas = async (muestraId: number, tazas: number[]) => {
await actualizarMuestra(muestraId, { tazasDefectuosas: tazas })
}
/**
* Actualiza el tipo de defecto
*/
const actualizarDefecto = async (muestraId: number, defecto: string | null) => {
await actualizarMuestra(muestraId, { defecto: defecto as any })
}
/**
* Actualiza sensaciones en boca
*/
const actualizarSensacionBoca = async (muestraId: number, sensaciones: string[]) => {
await actualizarMuestra(muestraId, { sensacionEnBoca: sensaciones as any })
}
/**
* Actualiza gustos predominantes (máximo 2)
*/
const actualizarGustosPredominantes = async (muestraId: number, gustos: string[]) => {
if (gustos.length > 2) {
throw new Error('Máximo 2 gustos predominantes permitidos')
}
if (gustos.length < 1) {
throw new Error('Mínimo 1 gusto predominante requerido')
}
await actualizarMuestra(muestraId, { gustosPredominantes: gustos as any })
}
/**
* Actualiza notas adicionales
*/
const actualizarOtrasNotas = async (muestraId: number, notas: string) => {
await actualizarMuestra(muestraId, { otrasNotas: notas })
}
/**
* Obtiene una muestra específica
*/
const obtenerMuestra = (muestraId: number): Muestra | null => {
if (!sesionActiva.value) return null
return sesionActiva.value.muestras.find(m => m.muestraId === muestraId) || null
}
/**
* Verifica si una muestra está completa (tiene todos los campos requeridos)
*/
const esMuestraCompleta = (muestra: Muestra): boolean => {
// Verificar intensidades afectivas (son las que cuentan para el puntaje)
const intensidadesCompletas = Object.values(muestra.intensidades).every(
(intensidad) => intensidad.afectiva !== null
)
// Verificar notas de fragancia/aroma
const fraganciaAromaCompleta = muestra.fraganciaAromaNotas.categoria !== null
// Verificar notas de sabor
const saborCompleto = muestra.saborNotas.categoria !== null
// Verificar gustos predominantes (mínimo 1, máximo 2)
const gustosCompletos = muestra.gustosPredominantes.length >= 1 && muestra.gustosPredominantes.length <= 2
return intensidadesCompletas && fraganciaAromaCompleta && saborCompleto && gustosCompletos
}
/**
* Obtiene el porcentaje de completitud de una muestra
*/
const porcentajeCompletitud = (muestra: Muestra): number => {
let total = 0
let completados = 0
// Intensidades afectivas (8 campos)
total += 8
completados += Object.values(muestra.intensidades).filter(
(intensidad) => intensidad.afectiva !== null
).length
// Intensidades descriptivas (8 campos)
total += 8
completados += Object.values(muestra.intensidades).filter(
(intensidad) => intensidad.descriptiva !== null
).length
// Notas fragancia/aroma
total += 1
if (muestra.fraganciaAromaNotas.categoria) completados += 1
// Notas sabor
total += 1
if (muestra.saborNotas.categoria) completados += 1
// Gustos predominantes
total += 1
if (muestra.gustosPredominantes.length >= 1) completados += 1
return Math.round((completados / total) * 100)
}
/**
* Elimina la sesión actual
*/
const eliminarSesionActual = async () => {
try {
await eliminar()
tabActiva.value = 'fragancia-aroma'
accordionAbierto.value = []
} catch (err) {
console.error('Error al eliminar sesión:', err)
throw err
}
}
/**
* Exporta la sesión actual como JSON
*/
const exportarSesion = (): string | null => {
if (!sesionActiva.value) return null
return JSON.stringify(sesionActiva.value, null, 2)
}
/**
* Calcula estadísticas generales de la sesión
*/
const estadisticasSesion = computed(() => {
if (!sesionActiva.value) return null
const muestras = sesionActiva.value.muestras
const totalMuestras = muestras.length
const muestrasCompletas = muestras.filter(esMuestraCompleta).length
const promedioCompletitud = muestras.reduce((acc, m) => acc + porcentajeCompletitud(m), 0) / totalMuestras
const puntajePromedio = muestras.reduce((acc, m) => acc + m.puntajeFinal, 0) / totalMuestras
return {
totalMuestras,
muestrasCompletas,
porcentajeCompletitud: Math.round(promedioCompletitud),
puntajePromedio: Math.round(puntajePromedio),
}
})
return {
// Estado de la sesión
sesionActiva,
cargando,
error,
// Estado de la UI
tabActiva,
accordionAbierto,
// Estadísticas
estadisticasSesion,
// Métodos CRUD de sesión
crearNuevaSesion,
eliminarSesionActual,
exportarSesion,
// Métodos de actualización de muestras
actualizarMuestra,
actualizarIntensidad,
actualizarNombreMuestra,
actualizarFraganciaAroma,
actualizarSabor,
actualizarTazasNoUniformes,
actualizarTazasDefectuosas,
actualizarDefecto,
actualizarSensacionBoca,
actualizarGustosPredominantes,
actualizarOtrasNotas,
// Utilidades
obtenerMuestra,
esMuestraCompleta,
porcentajeCompletitud,
}
}

View File

@@ -0,0 +1,288 @@
/**
* Composable para manejo de sesiones de catación en IndexedDB
* Maneja solo una sesión activa a la vez
*/
import type { SesionCatacion } from '~/types/catacion'
const DB_NAME = 'RioCataDB'
const DB_VERSION = 1
const STORE_NAME = 'sesiones'
const ACTIVE_SESSION_KEY = 'sesion-activa'
/**
* Inicializa y retorna la base de datos IndexedDB
*/
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
// Verificar si estamos en el cliente
if (typeof window === 'undefined' || !window.indexedDB) {
reject(new Error('IndexedDB no está disponible en este entorno'))
return
}
const request = window.indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error('Error al abrir la base de datos'))
}
request.onsuccess = () => {
resolve(request.result)
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
// Crear object store si no existe
if (!db.objectStoreNames.contains(STORE_NAME)) {
const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'sessionId' })
// Crear índice por fecha para consultas futuras
objectStore.createIndex('fecha', 'fecha', { unique: false })
}
}
})
}
/**
* Guarda o actualiza la sesión activa en IndexedDB
*/
async function saveSession(sesion: SesionCatacion): Promise<void> {
try {
const db = await openDatabase()
const transaction = db.transaction([STORE_NAME], 'readwrite')
const objectStore = transaction.objectStore(STORE_NAME)
// Eliminar todas las sesiones anteriores
await new Promise<void>((resolve, reject) => {
const clearRequest = objectStore.clear()
clearRequest.onsuccess = () => resolve()
clearRequest.onerror = () => reject(new Error('Error al limpiar sesiones anteriores'))
})
// Guardar la nueva sesión
await new Promise<void>((resolve, reject) => {
const addRequest = objectStore.add(sesion)
addRequest.onsuccess = () => resolve()
addRequest.onerror = () => reject(new Error('Error al guardar la sesión'))
})
db.close()
} catch (error) {
console.error('Error en saveSession:', error)
throw error
}
}
/**
* Carga la sesión activa desde IndexedDB
*/
async function loadSession(): Promise<SesionCatacion | null> {
try {
const db = await openDatabase()
const transaction = db.transaction([STORE_NAME], 'readonly')
const objectStore = transaction.objectStore(STORE_NAME)
const sesion = await new Promise<SesionCatacion | null>((resolve, reject) => {
// Obtener todas las claves
const getAllRequest = objectStore.getAll()
getAllRequest.onsuccess = () => {
const sesiones = getAllRequest.result as SesionCatacion[]
// Retornar la primera sesión (debería haber solo una)
const sesion = sesiones.length > 0 ? sesiones[0] : null
resolve(sesion || null)
}
getAllRequest.onerror = () => {
reject(new Error('Error al cargar la sesión'))
}
})
db.close()
return sesion
} catch (error) {
console.error('Error en loadSession:', error)
return null
}
}
/**
* Verifica si existe una sesión activa
*/
async function hasActiveSession(): Promise<boolean> {
try {
const sesion = await loadSession()
return sesion !== null
} catch (error) {
console.error('Error en hasActiveSession:', error)
return false
}
}
/**
* Elimina la sesión activa
*/
async function deleteSession(): Promise<void> {
try {
const db = await openDatabase()
const transaction = db.transaction([STORE_NAME], 'readwrite')
const objectStore = transaction.objectStore(STORE_NAME)
await new Promise<void>((resolve, reject) => {
const clearRequest = objectStore.clear()
clearRequest.onsuccess = () => resolve()
clearRequest.onerror = () => reject(new Error('Error al eliminar la sesión'))
})
db.close()
} catch (error) {
console.error('Error en deleteSession:', error)
throw error
}
}
/**
* Actualiza una sesión existente (timestamp de modificación)
*/
async function updateSession(sesion: SesionCatacion): Promise<void> {
try {
// Actualizar timestamp de modificación
sesion.modificadoEn = Date.now()
const db = await openDatabase()
const transaction = db.transaction([STORE_NAME], 'readwrite')
const objectStore = transaction.objectStore(STORE_NAME)
await new Promise<void>((resolve, reject) => {
const putRequest = objectStore.put(sesion)
putRequest.onsuccess = () => resolve()
putRequest.onerror = () => reject(new Error('Error al actualizar la sesión'))
})
db.close()
} catch (error) {
console.error('Error en updateSession:', error)
throw error
}
}
/**
* Composable principal para manejo de IndexedDB
*/
export const useIndexedDB = () => {
// Estado reactivo de la sesión
const sesionActiva = useState<SesionCatacion | null>('sesion-activa', () => null)
const cargando = useState<boolean>('sesion-cargando', () => false)
const error = useState<Error | null>('sesion-error', () => null)
/**
* Inicializa el composable cargando la sesión activa
*/
const inicializar = async () => {
if (import.meta.server) {
// No hacer nada en el servidor
return
}
try {
cargando.value = true
error.value = null
const sesion = await loadSession()
sesionActiva.value = sesion
} catch (err) {
error.value = err as Error
console.error('Error al inicializar IndexedDB:', err)
} finally {
cargando.value = false
}
}
/**
* Guarda la sesión activa
*/
const guardar = async (sesion: SesionCatacion) => {
if (import.meta.server) {
console.warn('No se puede guardar en IndexedDB desde el servidor')
return
}
try {
cargando.value = true
error.value = null
await saveSession(sesion)
sesionActiva.value = sesion
} catch (err) {
error.value = err as Error
console.error('Error al guardar sesión:', err)
throw err
} finally {
cargando.value = false
}
}
/**
* Actualiza la sesión activa
*/
const actualizar = async (sesion: SesionCatacion) => {
if (import.meta.server) {
console.warn('No se puede actualizar en IndexedDB desde el servidor')
return
}
try {
cargando.value = true
error.value = null
await updateSession(sesion)
sesionActiva.value = sesion
} catch (err) {
error.value = err as Error
console.error('Error al actualizar sesión:', err)
throw err
} finally {
cargando.value = false
}
}
/**
* Elimina la sesión activa
*/
const eliminar = async () => {
if (import.meta.server) {
console.warn('No se puede eliminar de IndexedDB desde el servidor')
return
}
try {
cargando.value = true
error.value = null
await deleteSession()
sesionActiva.value = null
} catch (err) {
error.value = err as Error
console.error('Error al eliminar sesión:', err)
throw err
} finally {
cargando.value = false
}
}
/**
* Verifica si hay una sesión activa
*/
const tieneSecion = computed(() => sesionActiva.value !== null)
return {
// Estado
sesionActiva: readonly(sesionActiva),
cargando: readonly(cargando),
error: readonly(error),
tieneSecion,
// Métodos
inicializar,
guardar,
actualizar,
eliminar,
}
}

View File

@@ -0,0 +1,332 @@
<template>
<div class="cata-page cata-text">
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-4xl sm:text-5xl font-bold mb-3 cata-text dark:cata-glow">
RioCata
</h1>
<p class="text-lg cata-text opacity-75">
Sistema de Catación de Café
</p>
<hr class="cata-divider my-6 max-w-xs mx-auto">
</div>
<!-- Loading State -->
<div v-if="cargando" class="flex justify-center items-center py-20">
<div class="loading-spinner"></div>
</div>
<!-- Main Content -->
<div v-else class="space-y-6">
<!-- Sesión activa encontrada -->
<div v-if="tieneSecion" class="sesion-activa cata-fade-in">
<div class="cata-outline-box p-6 rounded-lg">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-2xl font-semibold mb-2 cata-text">
Sesión en Progreso
</h2>
<p class="text-sm cata-text opacity-75">
Encontramos una sesión de catación activa
</p>
</div>
<UIcon name="i-lucide-coffee" class="w-10 h-10 opacity-50" />
</div>
<!-- Información de la sesión -->
<div v-if="sesionActiva" class="space-y-3 mb-6">
<div class="info-row">
<span class="info-label cata-text">Catador:</span>
<span class="info-value cata-text font-semibold">{{ sesionActiva.catador }}</span>
</div>
<div class="info-row">
<span class="info-label cata-text">Fecha:</span>
<span class="info-value cata-text">{{ formatearFecha(sesionActiva.fecha) }}</span>
</div>
<div class="info-row">
<span class="info-label cata-text">Muestras:</span>
<span class="info-value cata-text">{{ sesionActiva.cantidadMuestras }}</span>
</div>
<div v-if="estadisticasSesion" class="info-row">
<span class="info-label cata-text">Progreso:</span>
<span class="info-value cata-text">{{ estadisticasSesion.porcentajeCompletitud }}%</span>
</div>
</div>
<!-- Botones de acción -->
<div class="flex flex-col sm:flex-row gap-3">
<button
class="cata-button flex-1"
@click="continuarSesion"
>
<UIcon name="i-lucide-play" class="w-4 h-4 inline mr-2" />
Continuar Sesión
</button>
<button
class="cata-button"
@click="mostrarDialogoNueva = true"
>
<UIcon name="i-lucide-file-plus" class="w-4 h-4 inline mr-2" />
Nueva Sesión
</button>
</div>
<!-- Advertencia de nueva sesión -->
<div v-if="mostrarDialogoNueva" class="mt-4 p-4 bg-transparent border-2 border-error/50 rounded-md">
<p class="text-sm cata-text text-error mb-3">
<UIcon name="i-lucide-alert-triangle" class="w-4 h-4 inline mr-1" />
Crear una nueva sesión eliminará la sesión actual permanentemente.
</p>
<div class="flex gap-2">
<button
class="cata-button px-3 py-1.5 text-sm"
@click="mostrarDialogoNueva = false"
>
Cancelar
</button>
<button
class="cata-button px-3 py-1.5 text-sm border-error text-error"
@click="mostrarFormNuevaSesion"
>
Continuar
</button>
</div>
</div>
</div>
</div>
<!-- No hay sesión activa -->
<div v-else class="no-sesion cata-fade-in">
<div class="cata-outline-box p-8 rounded-lg text-center">
<UIcon name="i-lucide-clipboard-list" class="w-16 h-16 mx-auto mb-4 opacity-50" />
<h2 class="text-2xl font-semibold mb-2 cata-text">
No hay sesión activa
</h2>
<p class="text-sm cata-text opacity-75 mb-6">
Comienza una nueva sesión de catación
</p>
<button
class="cata-button px-6 py-3"
@click="mostrarFormulario = true"
>
<UIcon name="i-lucide-plus-circle" class="w-5 h-5 inline mr-2" />
Nueva Sesión
</button>
</div>
</div>
<!-- Formulario de nueva sesión -->
<div v-if="mostrarFormulario" class="form-nueva-sesion cata-fade-in mt-6">
<div class="cata-outline-box p-6 rounded-lg">
<h3 class="text-xl font-semibold mb-4 cata-text">
Nueva Sesión de Catación
</h3>
<form @submit.prevent="crearSesion" class="space-y-4">
<!-- Nombre del catador -->
<div>
<label for="catador" class="block text-sm font-medium mb-2 cata-text">
Nombre del Catador <span class="text-error">*</span>
</label>
<input
id="catador"
v-model="formData.catador"
type="text"
required
class="cata-input w-full"
placeholder="Ingresa tu nombre"
>
</div>
<!-- Cantidad de muestras -->
<div>
<label for="cantidadMuestras" class="block text-sm font-medium mb-2 cata-text">
Cantidad de Muestras <span class="text-error">*</span>
</label>
<input
id="cantidadMuestras"
v-model.number="formData.cantidadMuestras"
type="number"
required
min="3"
max="7"
class="cata-input w-full"
placeholder="Entre 3 y 7 muestras"
>
<p class="text-xs cata-text opacity-60 mt-1">
Selecciona entre 3 y 7 muestras
</p>
</div>
<!-- Botones -->
<div class="flex gap-3 pt-2">
<button
type="button"
class="cata-button flex-1"
@click="cancelarFormulario"
>
Cancelar
</button>
<button
type="submit"
class="cata-button flex-1 border-2"
:disabled="creandoSesion"
>
<span v-if="creandoSesion">Creando...</span>
<span v-else>Crear Sesión</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Link para volver al home -->
<div class="text-center mt-12">
<NuxtLink
to="/"
class="text-sm cata-text opacity-60 hover:opacity-100 transition-opacity"
>
<UIcon name="i-lucide-arrow-left" class="w-4 h-4 inline mr-1" />
Volver al inicio
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {
sesionActiva,
cargando,
error,
estadisticasSesion,
crearNuevaSesion,
} = useCatacion()
const { inicializar, tieneSecion } = useIndexedDB()
// Estado del formulario
const mostrarFormulario = ref(false)
const mostrarDialogoNueva = ref(false)
const creandoSesion = ref(false)
const formData = reactive({
catador: '',
cantidadMuestras: 5,
})
// Inicializar al montar
onMounted(async () => {
await inicializar()
})
// Formatear fecha
const formatearFecha = (fecha: string): string => {
const date = new Date(fecha)
return date.toLocaleDateString('es-ES', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
// Continuar sesión existente
const continuarSesion = () => {
navigateTo('/cata/sesion')
}
// Mostrar formulario de nueva sesión
const mostrarFormNuevaSesion = () => {
mostrarDialogoNueva.value = false
mostrarFormulario.value = true
}
// Crear nueva sesión
const crearSesion = async () => {
try {
creandoSesion.value = true
// Validaciones
if (!formData.catador.trim()) {
alert('Por favor ingresa el nombre del catador')
return
}
if (formData.cantidadMuestras < 3 || formData.cantidadMuestras > 7) {
alert('La cantidad de muestras debe estar entre 3 y 7')
return
}
// Crear sesión
await crearNuevaSesion(formData.catador.trim(), formData.cantidadMuestras)
// Navegar a la sesión
navigateTo('/cata/sesion')
} catch (err) {
console.error('Error al crear sesión:', err)
alert('Error al crear la sesión. Por favor intenta de nuevo.')
} finally {
creandoSesion.value = false
}
}
// Cancelar formulario
const cancelarFormulario = () => {
mostrarFormulario.value = false
mostrarDialogoNueva.value = false
formData.catador = ''
formData.cantidadMuestras = 5
}
// Título de la página
useHead({
title: 'RioCata - Inicio',
})
</script>
<style scoped>
.info-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid;
border-color: color-mix(in srgb, var(--cata-primary) 20%, transparent);
}
.info-row:last-child {
border-bottom: 0;
}
.info-label {
font-size: 0.875rem;
opacity: 0.75;
}
.info-value {
font-size: 1rem;
}
/* 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);
}
</style>

View File

@@ -0,0 +1,430 @@
<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',
trigger: 'w-full',
}"
>
<!-- Header personalizado con ResumenMuestra -->
<template #default="{ item }">
<CataResumenMuestra
:muestra="item.muestra"
:porcentaje-completitud="porcentajeCompletitud(item.muestra)"
/>
</template>
<!-- Body con FormularioMuestra -->
<template
v-for="muestra in sesionActiva.muestras"
:key="muestra.muestraId"
#[`muestra-${muestra.muestraId}`]
>
<CataFormularioMuestra
:muestra="muestra"
:tab-activa="tabActiva"
/>
</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 { AccordionItem } from '@nuxt/ui'
import type { TabCatacion } from '~/composables/useCatacion'
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<AccordionItem[]>(() => {
if (!sesionActiva.value) return []
return sesionActiva.value.muestras.map((muestra) => ({
label: muestra.nombre,
value: `muestra-${muestra.muestraId}`,
slot: `muestra-${muestra.muestraId}`,
muestra: JSON.parse(JSON.stringify(muestra)), // Datos extra para el template (clonado profundo)
} as any))
})
// 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>

315
nuxt4/app/types/catacion.ts Normal file
View File

@@ -0,0 +1,315 @@
/**
* Tipos y definiciones para el sistema de catación de café
*/
// ============================================================================
// INTENSIDADES
// ============================================================================
export interface IntensidadValor {
/** Valor descriptivo: qué tan intensa es la característica (1-10) */
descriptiva: number | null
/** Valor afectivo: qué tan buena/mala es la característica (1-15) */
afectiva: number | null
}
export interface Intensidades {
fragancia: IntensidadValor
aroma: IntensidadValor
sabor: IntensidadValor
saborResidual: IntensidadValor
acidez: IntensidadValor
dulzor: IntensidadValor
sensacionBoca: IntensidadValor
impresionGlobal: IntensidadValor
}
// ============================================================================
// FAMILIAS DE NOTAS
// ============================================================================
export type CategoriaNotaPrincipal =
| 'Floral'
| 'Afrutado'
| 'Ácido/Fermentado'
| 'Verde Vegetal'
| 'Otro'
| 'Tostado'
| 'Nueces/Cacao'
| 'Especias'
| 'Dulce'
export interface FamiliasNotas {
Floral: Record<string, never> // Sin subcategorías
Afrutado: {
Bayas: string[]
'Frutas Deshidratadas': string[]
Cítricos: string[]
}
'Ácido/Fermentado': {
Ácido: string[]
Fermentado: string[]
}
'Verde Vegetal': Record<string, never>
Otro: {
Químico: string[]
'Humedad/Tierra': string[]
Madera: string[]
}
Tostado: {
Cereal: string[]
Quemado: string[]
Tabaco: string[]
}
'Nueces/Cacao': {
Nueces: string[]
Cacao: string[]
}
Especias: Record<string, never>
Dulce: {
Vainilla: string[]
'Azúcar Morena': string[]
}
}
export interface NotaSeleccionada {
/** Categoría principal seleccionada */
categoria: CategoriaNotaPrincipal | null
/** Subcategoría seleccionada (si aplica) */
subcategoria: string | null
/** Nota específica seleccionada o escrita libremente */
notaEspecifica: string | null
}
// ============================================================================
// DEFECTOS Y CARACTERÍSTICAS
// ============================================================================
export type TipoDefecto = 'Mohoso' | 'Fenólico' | 'Papa' | null
export type SensacionBoca =
| 'Áspero'
| 'Arenoso'
| 'Rugoso'
| 'Rasposo'
| 'Suave'
| 'Aterciopelado'
| 'Sedoso'
| 'Almibarado'
| 'Aceitoso'
| 'Metálico'
| 'Deja seca la boca'
| 'Astringente'
export type GustoPredominante = 'Salado' | 'Amargo' | 'Ácido' | 'Dulce' | 'Umami'
// ============================================================================
// MUESTRA
// ============================================================================
export interface Muestra {
/** ID único de la muestra */
muestraId: number
/** Nombre o código de la muestra */
nombre: string
/** Valores de intensidad para cada parámetro */
intensidades: Intensidades
/** Notas de fragancia y aroma */
fraganciaAromaNotas: NotaSeleccionada
/** Notas de sabor */
saborNotas: NotaSeleccionada
/** Tazas no uniformes (números 1-5) */
tazasNoUniformes: number[]
/** Tazas defectuosas (números 1-5) */
tazasDefectuosas: number[]
/** Tipo de defecto encontrado */
defecto: TipoDefecto
/** Sensaciones en la boca (múltiples selecciones) */
sensacionEnBoca: SensacionBoca[]
/** Gustos predominantes (máximo 2, mínimo 1) */
gustosPredominantes: GustoPredominante[]
/** Notas adicionales en texto libre */
otrasNotas: string
/** Puntaje final (suma de valores afectivos) */
puntajeFinal: number
}
// ============================================================================
// SESIÓN DE CATACIÓN
// ============================================================================
export interface SesionCatacion {
/** ID único de la sesión */
sessionId: string
/** Fecha de la catación */
fecha: string
/** Nombre del catador */
catador: string
/** Cantidad de muestras en esta sesión */
cantidadMuestras: number
/** Listado de muestras */
muestras: Muestra[]
/** Timestamp de creación */
creadoEn: number
/** Timestamp de última modificación */
modificadoEn: number
}
// ============================================================================
// HELPERS Y UTILIDADES
// ============================================================================
/**
* Crea una muestra vacía con valores por defecto
*/
export function crearMuestraVacia(id: number): Muestra {
return {
muestraId: id,
nombre: `Muestra ${id}`,
intensidades: {
fragancia: { descriptiva: null, afectiva: null },
aroma: { descriptiva: null, afectiva: null },
sabor: { descriptiva: null, afectiva: null },
saborResidual: { descriptiva: null, afectiva: null },
acidez: { descriptiva: null, afectiva: null },
dulzor: { descriptiva: null, afectiva: null },
sensacionBoca: { descriptiva: null, afectiva: null },
impresionGlobal: { descriptiva: null, afectiva: null },
},
fraganciaAromaNotas: {
categoria: null,
subcategoria: null,
notaEspecifica: null,
},
saborNotas: {
categoria: null,
subcategoria: null,
notaEspecifica: null,
},
tazasNoUniformes: [],
tazasDefectuosas: [],
defecto: null,
sensacionEnBoca: [],
gustosPredominantes: [],
otrasNotas: '',
puntajeFinal: 0,
}
}
/**
* Calcula el puntaje final sumando todos los valores afectivos
*/
export function calcularPuntajeFinal(muestra: Muestra): number {
const { intensidades } = muestra
let total = 0
// Sumar todos los valores afectivos que no sean null
Object.values(intensidades).forEach((intensidad) => {
if (intensidad.afectiva !== null) {
total += intensidad.afectiva
}
})
return total
}
/**
* Crea una sesión de catación vacía
*/
export function crearSesionVacia(
catador: string,
cantidadMuestras: number
): SesionCatacion {
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
const ahora = Date.now()
const fecha = new Date().toISOString().split('T')[0] || '' // YYYY-MM-DD
const muestras: Muestra[] = []
for (let i = 1; i <= cantidadMuestras; i++) {
muestras.push(crearMuestraVacia(i))
}
return {
sessionId,
fecha,
catador,
cantidadMuestras,
muestras,
creadoEn: ahora,
modificadoEn: ahora,
}
}
// ============================================================================
// CONSTANTES
// ============================================================================
/**
* Estructura jerárquica completa de familias de notas
*/
export const FAMILIAS_NOTAS_ESTRUCTURA: FamiliasNotas = {
Floral: {},
Afrutado: {
Bayas: [],
'Frutas Deshidratadas': [],
Cítricos: [],
},
'Ácido/Fermentado': {
Ácido: [],
Fermentado: [],
},
'Verde Vegetal': {},
Otro: {
Químico: [],
'Humedad/Tierra': [],
Madera: [],
},
Tostado: {
Cereal: [],
Quemado: [],
Tabaco: [],
},
'Nueces/Cacao': {
Nueces: [],
Cacao: [],
},
Especias: {},
Dulce: {
Vainilla: [],
'Azúcar Morena': [],
},
}
/**
* Lista de todas las sensaciones en boca disponibles
*/
export const SENSACIONES_BOCA: SensacionBoca[] = [
'Áspero',
'Arenoso',
'Rugoso',
'Rasposo',
'Suave',
'Aterciopelado',
'Sedoso',
'Almibarado',
'Aceitoso',
'Metálico',
'Deja seca la boca',
'Astringente',
]
/**
* Lista de todos los gustos predominantes disponibles
*/
export const GUSTOS_PREDOMINANTES: GustoPredominante[] = [
'Salado',
'Amargo',
'Ácido',
'Dulce',
'Umami',
]
/**
* Lista de tipos de defectos disponibles
*/
export const TIPOS_DEFECTOS: TipoDefecto[] = ['Mohoso', 'Fenólico', 'Papa', null]