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:
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>
|
||||
Reference in New Issue
Block a user