All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m1s
- **Nuevas tabs reorganizadas:** - Organoléptica: Selectores de familia de fragancia-aroma y sabor - Descriptiva/Afectiva: Todos los sliders de intensidad (incluye impresión global) - Defectos: Tazas no uniformes, defectuosas y tipo de defecto - Impresión Global: Vista completa con todos los componentes - **Selector de categorías mejorado:** - Permitir selección múltiple de categorías padre - Las subcategorías son la unión de las subcategorías de los padres seleccionados - Permitir selección múltiple de subcategorías - Actualizar resumen visual de selección - **Tipos actualizados:** - NotaSeleccionada ahora usa arrays para categorias y subcategorias - TabCatacion actualizado con las nuevas tabs - Funciones de actualización modificadas para trabajar con arrays - **Correcciones TypeScript:** - Usar JSON.parse(JSON.stringify()) para crear copias mutables de arrays readonly - Resolver incompatibilidades de tipos entre readonly y mutable arrays
407 lines
10 KiB
Vue
407 lines
10 KiB
Vue
<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 (selección múltiple) -->
|
|
<div class="nivel-container">
|
|
<h4 class="nivel-title cata-text">Categorías (selección múltiple)</h4>
|
|
<div class="categorias-grid">
|
|
<button
|
|
v-for="categoria in categoriasDisponibles"
|
|
:key="categoria"
|
|
type="button"
|
|
:class="[
|
|
'categoria-item',
|
|
'cata-checkbox',
|
|
{
|
|
'cata-checkbox-checked': modelValue.categorias.includes(categoria),
|
|
'disabled': disabled,
|
|
},
|
|
]"
|
|
:disabled="disabled"
|
|
@click="toggleCategoria(categoria)"
|
|
>
|
|
<span class="categoria-text cata-text">{{ categoria }}</span>
|
|
<UIcon
|
|
v-if="modelValue.categorias.includes(categoria)"
|
|
name="i-lucide-check-circle"
|
|
class="categoria-check"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nivel 2: Subcategorías (unión de todas las categorías seleccionadas, selección múltiple) -->
|
|
<div v-if="subcategoriasDisponibles.length > 0" class="nivel-container mt-4">
|
|
<h4 class="nivel-title cata-text">Subcategorías (selección múltiple)</h4>
|
|
<div class="subcategorias-grid">
|
|
<button
|
|
v-for="subcategoria in subcategoriasDisponibles"
|
|
:key="subcategoria"
|
|
type="button"
|
|
:class="[
|
|
'subcategoria-item',
|
|
'cata-checkbox',
|
|
{
|
|
'cata-checkbox-checked': modelValue.subcategorias.includes(subcategoria),
|
|
'disabled': disabled,
|
|
},
|
|
]"
|
|
:disabled="disabled"
|
|
@click="toggleSubcategoria(subcategoria)"
|
|
>
|
|
<span class="subcategoria-text cata-text">{{ subcategoria }}</span>
|
|
<UIcon
|
|
v-if="modelValue.subcategorias.includes(subcategoria)"
|
|
name="i-lucide-check"
|
|
class="subcategoria-check"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nivel 3: Nota específica (input libre) -->
|
|
<div v-if="modelValue.categorias.length > 0" 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">
|
|
<span class="font-semibold">Categorías:</span> {{ modelValue.categorias.join(', ') }}
|
|
</p>
|
|
<p v-if="modelValue.subcategorias.length > 0" class="text-sm cata-text mt-1">
|
|
<span class="font-semibold">Subcategorías:</span> {{ modelValue.subcategorias.join(', ') }}
|
|
</p>
|
|
<p v-if="modelValue.notaEspecifica" class="text-sm cata-text mt-1">
|
|
<span class="font-semibold">Nota:</span> {{ modelValue.notaEspecifica }}
|
|
</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 (unión de todas las categorías seleccionadas)
|
|
const subcategoriasDisponibles = computed<string[]>(() => {
|
|
if (props.modelValue.categorias.length === 0) return []
|
|
|
|
const subcategoriasSet = new Set<string>()
|
|
|
|
// Iterar sobre cada categoría seleccionada y agregar sus subcategorías
|
|
props.modelValue.categorias.forEach((categoria) => {
|
|
const familia = FAMILIAS_NOTAS_ESTRUCTURA[categoria as CategoriaNotaPrincipal]
|
|
if (familia && typeof familia === 'object') {
|
|
Object.keys(familia).forEach((subcategoria) => {
|
|
subcategoriasSet.add(subcategoria)
|
|
})
|
|
}
|
|
})
|
|
|
|
return Array.from(subcategoriasSet).sort()
|
|
})
|
|
|
|
// Verifica si la selección está completa
|
|
const seleccionCompleta = computed(() => {
|
|
return props.modelValue.categorias.length > 0
|
|
})
|
|
|
|
// Toggle categoría (agregar o quitar)
|
|
const toggleCategoria = (categoria: CategoriaNotaPrincipal) => {
|
|
if (props.disabled) return
|
|
|
|
const categorias = [...props.modelValue.categorias]
|
|
const index = categorias.indexOf(categoria)
|
|
|
|
if (index > -1) {
|
|
// Quitar categoría
|
|
categorias.splice(index, 1)
|
|
|
|
// Si ya no hay categorías, limpiar subcategorías también
|
|
if (categorias.length === 0) {
|
|
emit('update:modelValue', {
|
|
categorias: [],
|
|
subcategorias: [],
|
|
notaEspecifica: null,
|
|
})
|
|
notaEspecificaLocal.value = ''
|
|
return
|
|
}
|
|
} else {
|
|
// Agregar categoría
|
|
categorias.push(categoria)
|
|
}
|
|
|
|
// Filtrar subcategorías: mantener solo las que siguen siendo válidas
|
|
const subcategoriasValidas = new Set<string>()
|
|
categorias.forEach((cat) => {
|
|
const familia = FAMILIAS_NOTAS_ESTRUCTURA[cat as CategoriaNotaPrincipal]
|
|
if (familia && typeof familia === 'object') {
|
|
Object.keys(familia).forEach((sub) => subcategoriasValidas.add(sub))
|
|
}
|
|
})
|
|
|
|
const subcategoriasFiltradas = props.modelValue.subcategorias.filter((sub) =>
|
|
subcategoriasValidas.has(sub)
|
|
)
|
|
|
|
emit('update:modelValue', {
|
|
...props.modelValue,
|
|
categorias,
|
|
subcategorias: subcategoriasFiltradas,
|
|
})
|
|
}
|
|
|
|
// Toggle subcategoría (agregar o quitar)
|
|
const toggleSubcategoria = (subcategoria: string) => {
|
|
if (props.disabled) return
|
|
|
|
const subcategorias = [...props.modelValue.subcategorias]
|
|
const index = subcategorias.indexOf(subcategoria)
|
|
|
|
if (index > -1) {
|
|
// Quitar subcategoría
|
|
subcategorias.splice(index, 1)
|
|
} else {
|
|
// Agregar subcategoría
|
|
subcategorias.push(subcategoria)
|
|
}
|
|
|
|
emit('update:modelValue', {
|
|
...props.modelValue,
|
|
subcategorias,
|
|
})
|
|
}
|
|
|
|
// 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>
|