Files
cataRio/nuxt4/app/components/cata/ModalAsignacionRapida.vue
josedario87 4e6c5f7b5d
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m5s
Fix: Mejorar UX de inputs en asignación rápida
PROBLEMAS RESUELTOS:

1. Inputs actualizaban en cada tecla (@input):
   - Impedía borrar números (ej: no se podía borrar el 9)
   - Hacía malabares para cambiar valores

2. Rango incorrecto de Sumatoria Afectiva:
   - Era 9-90 pero debería ser 8-72
   - 8 categorías × 1 punto mínimo = 8
   - 8 categorías × 9 puntos máximo = 72

SOLUCIONES:

1. Cambio de eventos:
   - @input → @blur en ambos inputs
   - Ahora solo actualiza al perder el foco
   - Permite editar libremente (borrar, reescribir)

2. Rangos corregidos:
   - Sumatoria Afectiva: 8-72 (antes 9-90)
   - SCAA Score: 58.00-100.00 (antes 58.75-112.00)
   - Valor por defecto: 40 (antes 45)

3. Validaciones mejoradas:
   - No valida mientras se escribe
   - Solo valida al perder foco (blur)
   - Maneja valores inválidos (NaN, null) reseteando a 40
   - Permite borrar y reescribir sin restricciones

BENEFICIOS:
- Ahora se puede borrar el 9 sin problemas
- Se puede escribir cualquier número sin interferencias
- La sincronización ocurre solo al terminar de editar
2025-10-19 02:46:18 -06:00

367 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<UModal
v-model:open="isOpen"
:ui="{
overlay: 'modal-overlay',
content: 'cata-bg cata-outline-box',
header: 'cata-bg',
body: 'cata-bg',
footer: 'cata-bg',
title: 'cata-text'
}"
>
<!-- Trigger (slot por defecto) -->
<slot />
<!-- Título personalizado -->
<template #title>
<span class="cata-text">Asignación Rápida de Puntajes</span>
</template>
<!-- Contenido del modal -->
<template #body>
<div class="space-y-4">
<!-- Paso 1: Ingresar puntaje total deseado -->
<div v-if="paso === 1" class="space-y-3">
<!-- Input de Sumatoria Afectiva -->
<div>
<label class="block text-xs cata-text opacity-75 mb-1 text-center">
Sumatoria Afectiva
</label>
<input
v-model.number="puntajeDeseado"
type="number"
:min="8"
:max="72"
step="1"
placeholder="8-72"
class="cata-input w-full px-3 py-2 rounded-md text-center text-lg"
@blur="onSumatoriaChange"
/>
</div>
<!-- Input de SCAA Score -->
<div>
<label class="block text-xs cata-text opacity-75 mb-1 text-center">
SCAA Score
</label>
<input
v-model.number="scaaDeseado"
type="number"
:min="scaaMin"
:max="scaaMax"
step="0.25"
placeholder="58.00-100.00"
class="cata-input w-full px-3 py-2 rounded-md text-center text-lg"
@blur="onScaaChange"
/>
</div>
</div>
<!-- Paso 2: Seleccionar categorías que sobresalen o palidecen -->
<div v-if="paso === 2">
<!-- Subtítulo con icono y contador -->
<div class="flex items-center gap-2 mb-3 cata-text font-medium">
<UIcon
:name="multiploMasCercano < puntajeDeseado ? 'i-heroicons-arrow-up-circle' : 'i-heroicons-arrow-down-circle'"
class="w-5 h-5"
:style="{ color: 'var(--cata-primary)' }"
/>
<span>{{ multiploMasCercano < puntajeDeseado ? 'Sobresale' : 'Palidece' }}</span>
<span class="ml-auto opacity-60">{{ categoriasSeleccionadas.length }}/{{ diferencia }}</span>
</div>
<div class="space-y-2">
<button
v-for="cat in categoriasDisponibles"
:key="cat.key"
type="button"
:class="[
'w-full px-3 py-2 rounded-md transition-all text-left flex items-center gap-2',
'cata-outline-box',
categoriasSeleccionadas.includes(cat.key)
? 'selected-category'
: ''
]"
:disabled="!categoriasSeleccionadas.includes(cat.key) && categoriasSeleccionadas.length >= diferencia"
@click="toggleCategoria(cat.key)"
>
<UIcon :name="cat.icon" class="w-4 h-4" :style="{ color: cat.color }" />
<span class="cata-text">{{ cat.label }}</span>
<UIcon
v-if="categoriasSeleccionadas.includes(cat.key)"
:name="multiploMasCercano < puntajeDeseado ? 'i-heroicons-arrow-up-circle-solid' : 'i-heroicons-arrow-down-circle-solid'"
class="w-5 h-5 ml-auto"
:style="{ color: cat.color }"
/>
</button>
</div>
</div>
</div>
</template>
<!-- Footer con botones -->
<template #footer>
<div class="flex items-center justify-between w-full">
<!-- Puntaje target a la izquierda -->
<div v-if="paso === 2" class="flex items-center gap-2 cata-text font-semibold">
<UIcon name="i-lucide-target" class="w-5 h-5" :style="{ color: 'var(--cata-primary)' }" />
<span>{{ puntajeDeseado }}</span>
</div>
<div v-else></div>
<!-- Botones a la derecha -->
<div class="flex items-center gap-2">
<button
class="cata-button px-4 py-2"
@click="cerrar"
>
Cancelar
</button>
<button
v-if="paso === 1"
class="cata-button px-4 py-2"
:disabled="!puntajeValido"
:class="{ 'opacity-50 cursor-not-allowed': !puntajeValido }"
@click="siguientePaso"
>
Continuar
</button>
<button
v-if="paso === 2"
class="cata-button px-4 py-2"
:disabled="categoriasSeleccionadas.length !== diferencia"
:class="{ 'opacity-50 cursor-not-allowed': categoriasSeleccionadas.length !== diferencia }"
@click="aplicarAsignacion"
>
Aplicar
</button>
</div>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import type { Muestra } from '~/types/catacion'
import { scaaASumatoriaAfectiva, sumatoriaAfectivaASCAA, redondearA025 } from '~/types/catacion'
interface Props {
modelValue: boolean
muestra: Muestra
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'aplicar': [puntajes: Record<string, number>]
}>()
// Categorías disponibles
const categoriasDisponibles = [
{ key: 'fragancia', label: 'Fragancia', icon: 'i-lucide-flower-2', color: '#8B7AB8' },
{ key: 'aroma', label: 'Aroma', icon: 'i-lucide-wind', color: '#26A69A' },
{ key: 'sabor', label: 'Sabor', icon: 'i-lucide-candy', color: '#E53935' },
{ key: 'saborResidual', label: 'Sabor Residual', icon: 'i-lucide-timer', color: '#F57C00' },
{ key: 'acidez', label: 'Acidez', icon: 'i-lucide-citrus', color: '#FDD835' },
{ key: 'dulzor', label: 'Dulzor', icon: 'i-lucide-cookie', color: '#EC407A' },
{ key: 'sensacionBoca', label: 'Sensación en Boca', icon: 'i-lucide-droplets', color: '#1E88E5' },
{ key: 'impresionGlobal', label: 'Impresión Global', icon: 'i-lucide-star', color: '#00ACC1' },
]
// Estado del modal
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// Estado del formulario
const paso = ref(1)
const puntajeDeseado = ref<number>(40)
const scaaDeseado = ref<number>(sumatoriaAfectivaASCAA(40))
const categoriasSeleccionadas = ref<string[]>([])
// Flag para evitar loops infinitos en la sincronización
const actualizandoInput = ref(false)
// Límites de SCAA Score (basados en sumatoria afectiva 8-72)
// 8 categorías × 1 punto mínimo = 8
// 8 categorías × 9 puntos máximo = 72
const scaaMin = sumatoriaAfectivaASCAA(8)
const scaaMax = sumatoriaAfectivaASCAA(72)
// Resetear estado cuando el modal se abre
watch(isOpen, (newValue) => {
if (newValue) {
paso.value = 1
puntajeDeseado.value = 40
scaaDeseado.value = sumatoriaAfectivaASCAA(40)
categoriasSeleccionadas.value = []
}
})
// Handler para cuando se modifica Sumatoria Afectiva (solo en blur)
const onSumatoriaChange = () => {
if (actualizandoInput.value) return
actualizandoInput.value = true
// Si el valor es inválido (NaN, null, undefined), resetear a default
if (!puntajeDeseado.value || isNaN(puntajeDeseado.value)) {
puntajeDeseado.value = 40
}
// Redondear a entero
puntajeDeseado.value = Math.round(puntajeDeseado.value)
// Validar rango (solo después de redondear)
if (puntajeDeseado.value < 8) puntajeDeseado.value = 8
if (puntajeDeseado.value > 72) puntajeDeseado.value = 72
// Actualizar SCAA Score
scaaDeseado.value = sumatoriaAfectivaASCAA(puntajeDeseado.value)
actualizandoInput.value = false
}
// Handler para cuando se modifica SCAA Score (solo en blur)
const onScaaChange = () => {
if (actualizandoInput.value) return
actualizandoInput.value = true
// Si el valor es inválido (NaN, null, undefined), resetear a default
if (!scaaDeseado.value || isNaN(scaaDeseado.value)) {
scaaDeseado.value = sumatoriaAfectivaASCAA(40)
}
// Redondear al múltiplo de 0.25 más cercano
scaaDeseado.value = redondearA025(scaaDeseado.value)
// Validar rango (solo después de redondear)
if (scaaDeseado.value < scaaMin) scaaDeseado.value = scaaMin
if (scaaDeseado.value > scaaMax) scaaDeseado.value = scaaMax
// Convertir a Sumatoria Afectiva
const sumatoriaCalculada = scaaASumatoriaAfectiva(scaaDeseado.value)
// Redondear al entero más cercano (sumatoria afectiva debe ser entero)
const sumatoriaRedondeada = Math.round(sumatoriaCalculada)
// Asegurar rango válido
puntajeDeseado.value = Math.max(8, Math.min(72, sumatoriaRedondeada))
// Recalcular SCAA Score con el valor redondeado (ya aplicará redondeo a 0.25)
scaaDeseado.value = sumatoriaAfectivaASCAA(puntajeDeseado.value)
actualizandoInput.value = false
}
// Cálculos
const puntajeValido = computed(() => {
return puntajeDeseado.value >= 8 && puntajeDeseado.value <= 72
})
const multiploMasCercano = computed(() => {
if (!puntajeValido.value) return 0
const valorActual = puntajeDeseado.value
const multiploInferior = Math.floor(valorActual / 8) * 8
const multiploSuperior = Math.ceil(valorActual / 8) * 8
const distInferior = Math.abs(valorActual - multiploInferior)
const distSuperior = Math.abs(valorActual - multiploSuperior)
// Si las distancias son iguales, elegir el mayor
if (distInferior === distSuperior) {
return multiploSuperior
}
return distInferior < distSuperior ? multiploInferior : multiploSuperior
})
const puntajeBase = computed(() => {
return multiploMasCercano.value / 8
})
const diferencia = computed(() => {
return Math.abs(puntajeDeseado.value - multiploMasCercano.value)
})
// Métodos
const siguientePaso = () => {
if (!puntajeValido.value) return
// Si el múltiplo es exacto (diferencia = 0), aplicar directamente
if (diferencia.value === 0) {
aplicarAsignacionDirecta()
} else {
paso.value = 2
}
}
const toggleCategoria = (key: string) => {
const index = categoriasSeleccionadas.value.indexOf(key)
if (index >= 0) {
categoriasSeleccionadas.value.splice(index, 1)
} else {
if (categoriasSeleccionadas.value.length < diferencia.value) {
categoriasSeleccionadas.value.push(key)
}
}
}
const aplicarAsignacionDirecta = () => {
const puntajes: Record<string, number> = {}
categoriasDisponibles.forEach(cat => {
puntajes[cat.key] = puntajeBase.value
})
emit('aplicar', puntajes)
cerrar()
}
const aplicarAsignacion = () => {
if (categoriasSeleccionadas.value.length !== diferencia.value) return
const puntajes: Record<string, number> = {}
const ajuste = multiploMasCercano.value < puntajeDeseado.value ? 1 : -1
categoriasDisponibles.forEach(cat => {
const esSeleccionada = categoriasSeleccionadas.value.includes(cat.key)
puntajes[cat.key] = puntajeBase.value + (esSeleccionada ? ajuste : 0)
})
emit('aplicar', puntajes)
cerrar()
}
const cerrar = () => {
isOpen.value = false
}
</script>
<style>
.modal-overlay {
background-color: var(--cata-bg) !important;
opacity: 0.85 !important;
backdrop-filter: blur(4px);
}
</style>
<style scoped>
.selected-category {
background-color: color-mix(in srgb, var(--cata-primary) 10%, transparent);
border-color: var(--cata-primary);
}
.dark .selected-category {
background-color: color-mix(in srgb, var(--cata-primary) 15%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--cata-primary) 20%, transparent);
}
</style>