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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
471
nuxt4/app/components/cata/FormularioMuestra.vue
Normal file
471
nuxt4/app/components/cata/FormularioMuestra.vue
Normal 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>
|
||||
309
nuxt4/app/components/cata/ResumenMuestra.vue
Normal file
309
nuxt4/app/components/cata/ResumenMuestra.vue
Normal 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>
|
||||
374
nuxt4/app/components/cata/SelectorFamilia.vue
Normal file
374
nuxt4/app/components/cata/SelectorFamilia.vue
Normal 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>
|
||||
227
nuxt4/app/components/cata/SelectorTazas.vue
Normal file
227
nuxt4/app/components/cata/SelectorTazas.vue
Normal 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>
|
||||
166
nuxt4/app/components/cata/SliderIntensidad.vue
Normal file
166
nuxt4/app/components/cata/SliderIntensidad.vue
Normal 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>
|
||||
346
nuxt4/app/composables/useCatacion.ts
Normal file
346
nuxt4/app/composables/useCatacion.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
288
nuxt4/app/composables/useIndexedDB.ts
Normal file
288
nuxt4/app/composables/useIndexedDB.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
332
nuxt4/app/pages/cata/index.vue
Normal file
332
nuxt4/app/pages/cata/index.vue
Normal 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>
|
||||
430
nuxt4/app/pages/cata/sesion.vue
Normal file
430
nuxt4/app/pages/cata/sesion.vue
Normal 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
315
nuxt4/app/types/catacion.ts
Normal 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]
|
||||
Reference in New Issue
Block a user