Files
cataRio/nuxt4/app/components/cata/SelectorTazas.vue
josedario87 87fb92d210
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m3s
Feat: Implementar UI completa de RioCata - Sistema de catación de café
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
2025-10-18 01:39:27 -06:00

228 lines
4.5 KiB
Vue

<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>