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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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