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:
165
nuxt4/app.config.ts
Normal file
165
nuxt4/app.config.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
// ========================================================================
|
||||||
|
// COLORES PERSONALIZADOS - MODO CLARO Y OSCURO
|
||||||
|
// ========================================================================
|
||||||
|
colors: {
|
||||||
|
// Modo claro: Paleta de azules (celeste a oscuro)
|
||||||
|
primary: {
|
||||||
|
50: '#e0f2fe', // Azul muy claro
|
||||||
|
100: '#bae6fd', // Azul celeste claro
|
||||||
|
200: '#87CEEB', // Azul celeste (SkyBlue)
|
||||||
|
300: '#7dd3fc', // Azul celeste medio
|
||||||
|
400: '#38bdf8', // Azul medio
|
||||||
|
500: '#4682B4', // Azul acero (SteelBlue)
|
||||||
|
600: '#0284c7', // Azul más intenso
|
||||||
|
700: '#0369a1', // Azul oscuro
|
||||||
|
800: '#075985', // Azul muy oscuro
|
||||||
|
900: '#00008B', // Azul oscuro intenso (DarkBlue)
|
||||||
|
950: '#000066', // Azul casi negro
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CONFIGURACIÓN DE VARIABLES CSS PERSONALIZADAS
|
||||||
|
// ========================================================================
|
||||||
|
variables: {
|
||||||
|
light: {
|
||||||
|
background: '0 0% 100%', // Blanco puro
|
||||||
|
foreground: '0 0% 0%', // Negro puro
|
||||||
|
|
||||||
|
// Colores primarios para modo claro (azul)
|
||||||
|
primary: '203 92% 54%', // #4682B4 (SteelBlue)
|
||||||
|
'primary-foreground': '0 0% 0%', // Texto negro sobre azul
|
||||||
|
|
||||||
|
// Bordes y outlines en azul
|
||||||
|
border: '203 50% 70%', // Azul celeste para bordes
|
||||||
|
muted: '203 30% 40%', // Azul oscuro para texto secundario
|
||||||
|
'muted-foreground': '0 0% 20%', // Gris oscuro
|
||||||
|
|
||||||
|
// Superficies con outline
|
||||||
|
elevated: '0 0% 100%', // Blanco (sin relleno)
|
||||||
|
accented: '203 92% 94%', // Azul muy claro para hover
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: '0 0% 0%', // Negro puro
|
||||||
|
foreground: '120 100% 50%', // Verde terminal (#00FF00)
|
||||||
|
|
||||||
|
// Colores primarios para modo oscuro (verde terminal)
|
||||||
|
primary: '120 100% 50%', // #00FF00 (Verde terminal)
|
||||||
|
'primary-foreground': '0 0% 0%', // Negro sobre verde
|
||||||
|
|
||||||
|
// Bordes y outlines en verde
|
||||||
|
border: '120 100% 40%', // Verde oscuro para bordes
|
||||||
|
muted: '120 80% 30%', // Verde apagado
|
||||||
|
'muted-foreground': '120 50% 60%', // Verde claro para texto secundario
|
||||||
|
|
||||||
|
// Superficies con outline
|
||||||
|
elevated: '0 0% 0%', // Negro (sin relleno)
|
||||||
|
accented: '120 100% 10%', // Verde muy oscuro para hover
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PERSONALIZACIÓN DE COMPONENTES
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// Accordion: Solo outlines, sin rellenos
|
||||||
|
accordion: {
|
||||||
|
slots: {
|
||||||
|
root: 'w-full',
|
||||||
|
item: 'border border-primary/30 last:border-b dark:border-primary/50',
|
||||||
|
header: 'flex',
|
||||||
|
trigger: 'group flex-1 flex items-center gap-1.5 font-medium text-sm py-3.5 px-4 focus-visible:outline-primary min-w-0 dark:font-mono',
|
||||||
|
content: 'data-[state=open]:animate-[accordion-down_200ms_ease-out] data-[state=closed]:animate-[accordion-up_200ms_ease-out] overflow-hidden focus:outline-none',
|
||||||
|
body: 'text-sm pb-3.5 px-4',
|
||||||
|
leadingIcon: 'shrink-0 size-5',
|
||||||
|
trailingIcon: 'shrink-0 size-5 ms-auto group-data-[state=open]:rotate-180 transition-transform duration-200',
|
||||||
|
label: 'text-start break-words dark:font-mono',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tabs: Estilo minimalista con outlines
|
||||||
|
tabs: {
|
||||||
|
slots: {
|
||||||
|
root: 'flex items-center gap-2',
|
||||||
|
list: 'relative flex p-0 group border-b border-primary/30 dark:border-primary/50',
|
||||||
|
indicator: 'absolute transition-[translate,width] duration-200 border-b-2 border-primary dark:border-primary',
|
||||||
|
trigger: [
|
||||||
|
'group relative inline-flex items-center min-w-0 px-4 py-2',
|
||||||
|
'data-[state=inactive]:text-muted hover:data-[state=inactive]:not-disabled:text-default',
|
||||||
|
'font-medium dark:font-mono',
|
||||||
|
'transition-colors',
|
||||||
|
'border-b-2 border-transparent',
|
||||||
|
'data-[state=active]:text-primary data-[state=active]:border-primary',
|
||||||
|
],
|
||||||
|
leadingIcon: 'shrink-0',
|
||||||
|
label: 'truncate dark:font-mono',
|
||||||
|
content: 'focus:outline-none w-full py-4',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal: {
|
||||||
|
root: 'flex-col',
|
||||||
|
list: 'w-full',
|
||||||
|
indicator: 'left-0 w-(--reka-tabs-indicator-size) translate-x-(--reka-tabs-indicator-position) bottom-0',
|
||||||
|
},
|
||||||
|
vertical: {
|
||||||
|
list: 'flex-col border-r border-b-0',
|
||||||
|
indicator: 'top-0 h-(--reka-tabs-indicator-size) translate-y-(--reka-tabs-indicator-position) right-0 border-r-2 border-b-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Slider: Personalización para intensidades
|
||||||
|
slider: {
|
||||||
|
slots: {
|
||||||
|
root: 'relative flex items-center select-none touch-none',
|
||||||
|
track: 'relative bg-transparent border border-primary/30 dark:border-primary/50 overflow-hidden rounded-full grow',
|
||||||
|
range: 'absolute rounded-full bg-primary/20 dark:bg-primary/30',
|
||||||
|
thumb: 'rounded-full bg-background ring-2 ring-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:ring-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// CheckboxGroup: Estilo outline
|
||||||
|
checkboxGroup: {
|
||||||
|
slots: {
|
||||||
|
root: 'relative',
|
||||||
|
fieldset: 'flex gap-x-2',
|
||||||
|
legend: 'mb-1 block font-medium text-default dark:font-mono',
|
||||||
|
item: 'border border-primary/30 dark:border-primary/50 px-3 py-2 rounded-md has-data-[state=checked]:border-primary dark:has-data-[state=checked]:border-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Button: Estilo outline
|
||||||
|
button: {
|
||||||
|
slots: {
|
||||||
|
base: 'border border-primary/50 dark:border-primary dark:font-mono',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
outline: {
|
||||||
|
base: 'bg-transparent border-2 border-primary text-primary hover:bg-primary/10 dark:hover:bg-primary/20',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Card: Solo outlines
|
||||||
|
card: {
|
||||||
|
slots: {
|
||||||
|
root: 'bg-transparent border border-primary/30 dark:border-primary/50 rounded-lg',
|
||||||
|
header: 'border-b border-primary/30 dark:border-primary/50 px-4 py-3',
|
||||||
|
body: 'px-4 py-3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Input: Outlines con focus
|
||||||
|
input: {
|
||||||
|
slots: {
|
||||||
|
root: 'bg-transparent border border-primary/50 dark:border-primary focus:ring-2 focus:ring-primary dark:font-mono',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,2 +1,355 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@nuxt/ui";
|
@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