Files
cataRio/nuxt4/app/components/cata/ModalAsignacionRapida.vue
josedario87 3ea68b195a
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m7s
Fix: Corregir nombre de componente ResumenMuestra en calculadora SCAA
2025-10-19 03:44:51 -06:00

639 lines
20 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">{{ tituloModal }}</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>
<UInputNumber
v-model="puntajeDeseado"
:min="8"
:max="72"
:step="1"
placeholder="8-72"
class="w-full cata-input-number"
variant="outline"
:ui="{
base: 'cata-input text-center text-lg',
}"
@update:model-value="onSumatoriaChangeFromButtons"
@blur="onSumatoriaBlur"
/>
</div>
<!-- Input de SCAA Score -->
<div>
<label class="block text-xs cata-text opacity-75 mb-1 text-center">
SCAA Score
</label>
<UInputNumber
v-model="scaaDeseado"
:min="scaaMin"
:max="scaaMax"
:step="0.25"
placeholder="58.00-100.00"
class="w-full cata-input-number"
variant="outline"
:ui="{
base: 'cata-input text-center text-lg',
}"
:format-options="{ minimumFractionDigits: 2, maximumFractionDigits: 2 }"
@update:model-value="onScaaChangeFromButtons"
@blur="onScaaBlur"
/>
</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>
<!-- Paso 3: Vista previa con ResumenMuestra (solo en modo calculadora) -->
<div v-if="paso === 3">
<div class="space-y-4">
<!-- Inputs de penalizaciones -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs cata-text opacity-75 mb-1">
Tazas No Uniformes
</label>
<UInputNumber
v-model="tazasNoUniformes"
:min="0"
:max="5"
:step="1"
placeholder="0-5"
class="w-full cata-input-number"
variant="outline"
:ui="{
base: 'cata-input text-center',
}"
/>
<p class="text-xs cata-text opacity-60 mt-1 text-center">
-2 puntos cada una
</p>
</div>
<div>
<label class="block text-xs cata-text opacity-75 mb-1">
Tazas Defectuosas
</label>
<UInputNumber
v-model="tazasDefectuosas"
:min="0"
:max="5"
:step="1"
placeholder="0-5"
class="w-full cata-input-number"
variant="outline"
:ui="{
base: 'cata-input text-center',
}"
/>
<p class="text-xs cata-text opacity-60 mt-1 text-center">
-4 puntos cada una
</p>
</div>
</div>
<!-- Resumen de la muestra -->
<div class="mt-6">
<h4 class="text-sm font-semibold cata-text mb-2 opacity-75">
Vista Previa
</h4>
<div class="cata-outline-box p-4 rounded-lg">
<CataResumenMuestra :muestra="muestraGenerica" tab-activa="impresion-global" />
</div>
</div>
<!-- Información adicional -->
<div class="text-xs cata-text opacity-60 text-center">
<p>Esta es una muestra de ejemplo para calcular el SCAA Score</p>
<p>Los valores descriptivos y organolépticos están ocultos</p>
</div>
</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 || paso === 3" 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">
<!-- Botón Volver (pasos 2 y 3) -->
<button
v-if="paso === 2 || paso === 3"
class="cata-button px-4 py-2"
@click="pasoAnterior"
>
Volver
</button>
<!-- Botón Cancelar (solo paso 1) -->
<button
v-if="paso === 1"
class="cata-button px-4 py-2"
@click="cerrar"
>
Cancelar
</button>
<!-- Botón Continuar (paso 1) -->
<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>
<!-- Botón Aplicar/Continuar (paso 2) -->
<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="siguientePasoOAplicar"
>
{{ modo === 'calculadora' ? 'Continuar' : 'Aplicar' }}
</button>
<!-- Botón Cerrar (paso 3 - solo modo calculadora) -->
<button
v-if="paso === 3"
class="cata-button px-4 py-2"
@click="cerrar"
>
Cerrar
</button>
</div>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import type { Muestra, IntensidadValor } from '~/types/catacion'
import { scaaASumatoriaAfectiva, sumatoriaAfectivaASCAA, redondearA025 } from '~/types/catacion'
interface Props {
modelValue: boolean
muestra?: Muestra
modo?: 'calculadora' | 'asignacion'
}
const props = withDefaults(defineProps<Props>(), {
modo: 'asignacion',
})
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[]>([])
// Estado de penalizaciones (paso 3)
const tazasNoUniformes = ref<number>(0)
const tazasDefectuosas = ref<number>(0)
// Flag para evitar loops infinitos en la sincronización
const actualizandoInput = ref(false)
// Título del modal según el modo
const tituloModal = computed(() => {
return props.modo === 'calculadora' ? 'Calculadora SCAA Score' : 'Asignación Rápida de Puntajes'
})
// 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 = []
tazasNoUniformes.value = 0
tazasDefectuosas.value = 0
}
})
// Crear muestra genérica para vista previa (modo calculadora)
const muestraGenerica = computed<Muestra>(() => {
// Primero calculamos los puntajes afectivos
const puntajes: Record<string, number> = {}
const puntajeBase = multiploMasCercano.value / 8
const ajuste = multiploMasCercano.value < puntajeDeseado.value ? 1 : -1
categoriasDisponibles.forEach(cat => {
const esSeleccionada = categoriasSeleccionadas.value.includes(cat.key)
puntajes[cat.key] = puntajeBase + (esSeleccionada ? ajuste : 0)
})
// Crear objeto de intensidades
const intensidades: Muestra['intensidades'] = {
fragancia: { descriptiva: null, afectiva: puntajes.fragancia || puntajeBase },
aroma: { descriptiva: null, afectiva: puntajes.aroma || puntajeBase },
sabor: { descriptiva: null, afectiva: puntajes.sabor || puntajeBase },
saborResidual: { descriptiva: null, afectiva: puntajes.saborResidual || puntajeBase },
acidez: { descriptiva: null, afectiva: puntajes.acidez || puntajeBase },
dulzor: { descriptiva: null, afectiva: puntajes.dulzor || puntajeBase },
sensacionBoca: { descriptiva: null, afectiva: puntajes.sensacionBoca || puntajeBase },
impresionGlobal: { descriptiva: null, afectiva: puntajes.impresionGlobal || puntajeBase },
}
// Crear arrays de tazas con defectos según las cantidades
const tazasNoUniformesArray: number[] = []
const tazasDefectuosasArray: number[] = []
for (let i = 1; i <= tazasNoUniformes.value; i++) {
tazasNoUniformesArray.push(i)
}
for (let i = 1; i <= tazasDefectuosas.value; i++) {
tazasDefectuosasArray.push(i)
}
return {
muestraId: 1,
nombre: 'Muestra de Ejemplo',
intensidades,
fraganciaAromaNotas: { categorias: [], subcategorias: [], notaEspecifica: null },
saborNotas: { categorias: [], subcategorias: [], notaEspecifica: null },
tazasNoUniformes: tazasNoUniformesArray,
tazasDefectuosas: tazasDefectuosasArray,
defecto: null,
sensacionEnBoca: null,
gustosPredominantes: [],
otrasNotas: '',
puntajeFinal: 0,
}
})
// Handler para cuando se modifica Sumatoria Afectiva usando botones +/-
const onSumatoriaChangeFromButtons = (newValue: number | null) => {
if (actualizandoInput.value) return
if (newValue === null || newValue === undefined) return
actualizandoInput.value = true
// El valor ya viene validado por UInputNumber (min/max/step)
puntajeDeseado.value = newValue
// Actualizar SCAA Score automáticamente
scaaDeseado.value = sumatoriaAfectivaASCAA(puntajeDeseado.value)
actualizandoInput.value = false
}
// Handler para cuando se pierde el foco en Sumatoria Afectiva (escritura manual)
const onSumatoriaBlur = () => {
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 usando botones +/-
const onScaaChangeFromButtons = (newValue: number | null) => {
if (actualizandoInput.value) return
if (newValue === null || newValue === undefined) return
actualizandoInput.value = true
// El valor ya viene validado por UInputNumber (min/max/step)
scaaDeseado.value = newValue
// Convertir a Sumatoria Afectiva
const sumatoriaCalculada = scaaASumatoriaAfectiva(scaaDeseado.value)
// Redondear al entero más cercano
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
scaaDeseado.value = sumatoriaAfectivaASCAA(puntajeDeseado.value)
actualizandoInput.value = false
}
// Handler para cuando se pierde el foco en SCAA Score (escritura manual)
const onScaaBlur = () => {
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)
if (diferencia.value === 0) {
if (props.modo === 'calculadora') {
// En modo calculadora, ir directo al paso 3
paso.value = 3
} else {
// En modo asignación, aplicar directamente
aplicarAsignacionDirecta()
}
} else {
// Hay diferencia, ir al paso 2
paso.value = 2
}
}
const pasoAnterior = () => {
if (paso.value > 1) {
paso.value--
}
}
const siguientePasoOAplicar = () => {
if (categoriasSeleccionadas.value.length !== diferencia.value) return
if (props.modo === 'calculadora') {
// En modo calculadora, ir al paso 3
paso.value = 3
} else {
// En modo asignación, aplicar
aplicarAsignacion()
}
}
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.95 !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Estilos para UInputNumber para que respete los colores del usuario */
:deep(.cata-input-number input) {
background-color: var(--cata-bg) !important;
color: var(--cata-text) !important;
border-color: color-mix(in srgb, var(--cata-primary) 30%, transparent) !important;
}
:deep(.cata-input-number input:focus) {
border-color: var(--cata-primary) !important;
outline: none !important;
box-shadow: 0 0 0 1px var(--cata-primary) !important;
}
:deep(.cata-input-number input::placeholder) {
color: color-mix(in srgb, var(--cata-text) 50%, transparent) !important;
}
/* Botones de incremento/decremento */
:deep(.cata-input-number button) {
color: var(--cata-primary) !important;
transition: all 0.2s ease;
}
:deep(.cata-input-number button:hover) {
color: var(--cata-primary) !important;
opacity: 0.8;
}
:deep(.cata-input-number button:active) {
transform: scale(0.95);
}
/* Modo oscuro */
.dark :deep(.cata-input-number input) {
background-color: var(--cata-bg) !important;
border-color: color-mix(in srgb, var(--cata-primary) 40%, transparent) !important;
}
.dark :deep(.cata-input-number input:focus) {
box-shadow: 0 0 0 1px var(--cata-primary), 0 0 8px color-mix(in srgb, var(--cata-primary) 30%, transparent) !important;
}
</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>