Feat: Implementar asignación rápida de puntajes con botón de nube cáustica
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m8s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m8s
- Crear ModalAsignacionRapida.vue con lógica de distribución por múltiplos de 8 - Input numérico (8-80) para puntaje total deseado - Cálculo automático del múltiplo de 8 más cercano - Si múltiplo < valor: permite seleccionar categorías que sobresalen (+1) - Si múltiplo > valor: permite seleccionar categorías que palidecen (-1) - Si múltiplo = valor: asignación directa sin ajustes - Crear BotonNubeCaustica.vue con diseño especial - Forma de nube usando SVG path - Animación de patrones de luz cáustica con gradientes animados - Efectos de brillo y ondas al hacer hover - Icono de rayo mágico con animación sparkle - Integrar funcionalidad en FormularioMuestra.vue - Ubicar botón en esquina superior izquierda de sección Descriptiva/Afectiva - Aplicar puntajes calculados a todas las categorías descriptivas - Actualizar puntaje final automáticamente - Corregir comentario: "Suma de valores descriptivos" (era "afectivos")
This commit is contained in:
247
nuxt4/app/components/cata/ModalAsignacionRapida.vue
Normal file
247
nuxt4/app/components/cata/ModalAsignacionRapida.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<UModal v-model="isOpen" :ui="{ width: 'sm:max-w-md' }">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Asignación Rápida de Puntajes</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="cerrar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Paso 1: Ingresar puntaje total deseado -->
|
||||
<div v-if="paso === 1">
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
Puntaje Total Deseado (8-80)
|
||||
</label>
|
||||
<UInput
|
||||
v-model.number="puntajeDeseado"
|
||||
type="number"
|
||||
:min="8"
|
||||
:max="80"
|
||||
placeholder="Ej: 45"
|
||||
:ui="{ base: 'text-center text-lg' }"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Este puntaje se distribuirá entre las 8 categorías descriptivas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Paso 2: Seleccionar categorías que sobresalen o palidecen -->
|
||||
<div v-if="paso === 2">
|
||||
<p class="text-sm mb-3">
|
||||
<span v-if="multiploMasCercano < puntajeDeseado">
|
||||
Selecciona {{ diferencia }} categoría(s) que <strong>sobresalen</strong> en esta muestra:
|
||||
</span>
|
||||
<span v-else>
|
||||
Selecciona {{ diferencia }} categoría(s) que <strong>palidecen</strong> en esta muestra:
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<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 border-2 transition-all text-left flex items-center gap-2',
|
||||
categoriasSeleccionadas.includes(cat.key)
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
: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>{{ cat.label }}</span>
|
||||
<UIcon
|
||||
v-if="categoriasSeleccionadas.includes(cat.key)"
|
||||
name="i-heroicons-check-circle-solid"
|
||||
class="w-5 h-5 ml-auto text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 mt-3">
|
||||
Seleccionadas: {{ categoriasSeleccionadas.length }} / {{ diferencia }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Resumen -->
|
||||
<div v-if="paso === 2" class="bg-gray-50 dark:bg-gray-800 rounded-md p-3 text-sm">
|
||||
<p class="font-medium mb-1">Distribución:</p>
|
||||
<p>• Puntaje base: {{ puntajeBase }} para todas las categorías</p>
|
||||
<p v-if="multiploMasCercano < puntajeDeseado">
|
||||
• +1 punto a las {{ diferencia }} categoría(s) que sobresalen
|
||||
</p>
|
||||
<p v-else>
|
||||
• -1 punto a las {{ diferencia }} categoría(s) que palidecen
|
||||
</p>
|
||||
<p class="font-semibold mt-2">Total: {{ puntajeDeseado }} puntos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
@click="cerrar"
|
||||
>
|
||||
Cancelar
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="paso === 1"
|
||||
:disabled="!puntajeValido"
|
||||
@click="siguientePaso"
|
||||
>
|
||||
Continuar
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="paso === 2"
|
||||
:disabled="categoriasSeleccionadas.length !== diferencia"
|
||||
@click="aplicarAsignacion"
|
||||
>
|
||||
Aplicar
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Muestra } 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-wind', color: '#8B7AB8' },
|
||||
{ key: 'aroma', label: 'Aroma', icon: 'i-lucide-nose', color: '#26A69A' },
|
||||
{ key: 'sabor', label: 'Sabor', icon: 'i-lucide-ice-cream-cone', color: '#E53935' },
|
||||
{ key: 'saborResidual', label: 'Sabor Residual', icon: 'i-lucide-timer', color: '#F57C00' },
|
||||
{ key: 'acidez', label: 'Acidez', icon: 'i-lucide-zap', color: '#FDD835' },
|
||||
{ key: 'dulzor', label: 'Dulzor', icon: 'i-lucide-candy', color: '#EC407A' },
|
||||
{ key: 'sensacionBoca', label: 'Sensación en Boca', icon: 'i-lucide-smile', 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 categoriasSeleccionadas = ref<string[]>([])
|
||||
|
||||
// Cálculos
|
||||
const puntajeValido = computed(() => {
|
||||
return puntajeDeseado.value >= 8 && puntajeDeseado.value <= 80
|
||||
})
|
||||
|
||||
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
|
||||
// Resetear estado
|
||||
setTimeout(() => {
|
||||
paso.value = 1
|
||||
puntajeDeseado.value = 40
|
||||
categoriasSeleccionadas.value = []
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user