Files
cataRio/nuxt4/app/composables/useCatacion.ts
josedario87 816a3e860a
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m7s
Fix: Refactorizar sensaciones en boca y hacer checkboxes ultra compactos
CAMBIOS EN SENSACIONES EN BOCA:
- Reducir opciones a solo 5: Áspero, Aceitoso, Metálico, Deja seca la boca, Suave
- Cambiar de selección múltiple a selección única
- Actualizar tipo de sensacionEnBoca: SensacionBoca[] → SensacionBoca | null

CAMBIOS EN CHECKBOXES (sensaciones y gustos):
- Hacer checkboxes tan compactos como subcategorías de SelectorFamilia
- Usar flex-wrap en todos los breakpoints (eliminar grid en desktop)
- Dimensiones ultra compactas:
  * Desktop: min-height 32px, padding 0.375rem 0.5rem, font-size 0.75rem
  * Mobile: min-height 28px, padding 0.25rem 0.375rem, font-size 0.6875rem
  * Touch: min-height 36px para dispositivos táctiles

ARCHIVOS MODIFICADOS:
- app/types/catacion.ts: Actualizar SensacionBoca y SENSACIONES_BOCA
- app/composables/useCatacion.ts: Cambiar actualizarSensacionBoca a selección única
- app/components/cata/FormularioMuestra.vue: UI compacta y selección única
- app/components/cata/ResumenMuestra.vue: Adaptar a sensacionEnBoca única
2025-10-18 16:55:37 -06:00

385 lines
11 KiB
TypeScript

/**
* Composable para manejar la lógica de negocio de catación
*/
import type { SesionCatacion, Muestra, IntensidadValor } from '~/types/catacion'
import { crearSesionVacia, calcularPuntajeFinal } from '~/types/catacion'
export type TabCatacion = 'organoleptica' | 'descriptiva-afectiva' | 'defectos' | 'impresion-global'
// Subcategorías para Organoléptica
export type SubcategoriaOrganoleptica = 'fragancia-aroma' | 'sabor' | 'sensacion-boca' | 'gustos-predominantes'
// Subcategorías para Descriptiva/Afectiva
export type SubcategoriaDescriptivaAfectiva = 'descriptiva' | 'afectiva' | 'fragancia' | 'aroma' | 'sabor' | 'sabor-residual' | 'acidez' | 'dulzor' | 'sensacion-boca' | 'impresion-global'
// Tipo unión de todas las subcategorías
export type Subcategoria = SubcategoriaOrganoleptica | SubcategoriaDescriptivaAfectiva | null
// Tipo para las subcategorías por tab
export type SubcategoriasPorTab = {
'organoleptica': Subcategoria[]
'descriptiva-afectiva': Subcategoria[]
'defectos': Subcategoria[]
'impresion-global': Subcategoria[]
}
// Clave para localStorage
const STORAGE_KEY_SUBCATEGORIAS = 'riocata-subcategorias-activas'
export const useCatacion = () => {
const { sesionActiva, cargando, error, guardar, actualizar, eliminar } = useIndexedDB()
// Cargar subcategorías desde localStorage
const cargarSubcategoriasDesdeStorage = (): SubcategoriasPorTab => {
if (process.client) {
try {
const stored = localStorage.getItem(STORAGE_KEY_SUBCATEGORIAS)
if (stored) {
return JSON.parse(stored)
}
} catch (err) {
console.warn('Error al cargar subcategorías desde localStorage:', err)
}
}
return {
'organoleptica': [],
'descriptiva-afectiva': [],
'defectos': [],
'impresion-global': [],
}
}
// Estado de la UI
const tabActiva = useState<TabCatacion>('tab-activa', () => 'organoleptica')
const subcategoriasPorTab = useState<SubcategoriasPorTab>('subcategorias-por-tab', cargarSubcategoriasDesdeStorage)
const accordionAbierto = useState<string | undefined>('accordion-abierto', () => undefined)
// Computed para obtener las subcategorías de la tab actual
const subcategoriasActivas = computed(() => subcategoriasPorTab.value[tabActiva.value])
/**
* Actualiza las subcategorías de la tab actual y las guarda en localStorage
*/
const actualizarSubcategorias = (subcategorias: Subcategoria[]) => {
subcategoriasPorTab.value[tabActiva.value] = subcategorias
if (process.client) {
try {
localStorage.setItem(STORAGE_KEY_SUBCATEGORIAS, JSON.stringify(subcategoriasPorTab.value))
} catch (err) {
console.warn('Error al guardar subcategorías en localStorage:', err)
}
}
}
/**
* Crea una nueva sesión de catación
*/
const crearNuevaSesion = async (catador: string, cantidadMuestras: number) => {
try {
const nuevaSesion = crearSesionVacia(catador, cantidadMuestras)
await guardar(nuevaSesion)
// Resetear estado de UI
tabActiva.value = 'organoleptica'
accordionAbierto.value = undefined
return nuevaSesion
} catch (err) {
console.error('Error al crear nueva sesión:', err)
throw err
}
}
/**
* Actualiza una muestra específica y guarda en IndexedDB
*/
const actualizarMuestra = async (muestraId: number, muestraActualizada: Partial<Muestra>) => {
if (!sesionActiva.value) {
throw new Error('No hay sesión activa')
}
try {
const muestra = sesionActiva.value.muestras.find(m => m.muestraId === muestraId)
if (!muestra) {
throw new Error(`Muestra con ID ${muestraId} no encontrada`)
}
// Actualizar muestra directamente (mutación)
Object.assign(muestra, muestraActualizada)
// Recalcular puntaje final si hay cambios en intensidades
if (muestraActualizada.intensidades) {
muestra.puntajeFinal = calcularPuntajeFinal(muestra)
}
// Guardar en IndexedDB
await actualizar(sesionActiva.value)
} catch (err) {
console.error('Error al actualizar muestra:', err)
throw err
}
}
/**
* Actualiza un valor de intensidad específico
*/
const actualizarIntensidad = async (
muestraId: number,
parametro: keyof Muestra['intensidades'],
tipo: 'descriptiva' | 'afectiva',
valor: number | null
) => {
if (!sesionActiva.value) {
throw new Error('No hay sesión activa')
}
try {
const muestra = sesionActiva.value.muestras.find(m => m.muestraId === muestraId)
if (!muestra) {
throw new Error(`Muestra con ID ${muestraId} no encontrada`)
}
// Actualizar valor de intensidad directamente (mutación)
muestra.intensidades[parametro][tipo] = valor
// Recalcular puntaje final
muestra.puntajeFinal = calcularPuntajeFinal(muestra)
// Guardar en IndexedDB (pasa la sesión actual, no un clon)
await actualizar(sesionActiva.value)
} catch (err) {
console.error('Error al actualizar intensidad:', err)
throw err
}
}
/**
* Actualiza el nombre de una muestra
*/
const actualizarNombreMuestra = async (muestraId: number, nombre: string) => {
await actualizarMuestra(muestraId, { nombre })
}
/**
* Actualiza las notas de fragancia/aroma
*/
const actualizarFraganciaAroma = async (
muestraId: number,
categorias: string[],
subcategorias: string[],
notaEspecifica: string | null
) => {
await actualizarMuestra(muestraId, {
fraganciaAromaNotas: { categorias: categorias as any, subcategorias, notaEspecifica },
})
}
/**
* Actualiza las notas de sabor
*/
const actualizarSabor = async (
muestraId: number,
categorias: string[],
subcategorias: string[],
notaEspecifica: string | null
) => {
await actualizarMuestra(muestraId, {
saborNotas: { categorias: categorias as any, subcategorias, notaEspecifica },
})
}
/**
* Actualiza tazas no uniformes
*/
const actualizarTazasNoUniformes = async (muestraId: number, tazas: number[]) => {
await actualizarMuestra(muestraId, { tazasNoUniformes: tazas })
}
/**
* Actualiza tazas defectuosas
*/
const actualizarTazasDefectuosas = async (muestraId: number, tazas: number[]) => {
await actualizarMuestra(muestraId, { tazasDefectuosas: tazas })
}
/**
* Actualiza el tipo de defecto
*/
const actualizarDefecto = async (muestraId: number, defecto: string | null) => {
await actualizarMuestra(muestraId, { defecto: defecto as any })
}
/**
* Actualiza sensación en boca (selección única)
*/
const actualizarSensacionBoca = async (muestraId: number, sensacion: string | null) => {
await actualizarMuestra(muestraId, { sensacionEnBoca: sensacion as any })
}
/**
* Actualiza gustos predominantes (máximo 2)
*/
const actualizarGustosPredominantes = async (muestraId: number, gustos: string[]) => {
if (gustos.length > 2) {
throw new Error('Máximo 2 gustos predominantes permitidos')
}
if (gustos.length < 1) {
throw new Error('Mínimo 1 gusto predominante requerido')
}
await actualizarMuestra(muestraId, { gustosPredominantes: gustos as any })
}
/**
* Actualiza notas adicionales
*/
const actualizarOtrasNotas = async (muestraId: number, notas: string) => {
await actualizarMuestra(muestraId, { otrasNotas: notas })
}
/**
* Obtiene una muestra específica
*/
const obtenerMuestra = (muestraId: number): Muestra | null => {
if (!sesionActiva.value) return null
const muestra = sesionActiva.value.muestras.find(m => m.muestraId === muestraId)
return muestra || null
}
/**
* Verifica si una muestra está completa (tiene todos los campos requeridos)
*/
const esMuestraCompleta = (muestra: Muestra): boolean => {
// Verificar intensidades afectivas (son las que cuentan para el puntaje)
const intensidadesCompletas = Object.values(muestra.intensidades).every(
(intensidad) => intensidad.afectiva !== null
)
// Verificar notas de fragancia/aroma (al menos una categoría)
const fraganciaAromaCompleta = muestra.fraganciaAromaNotas.categorias.length > 0
// Verificar notas de sabor (al menos una categoría)
const saborCompleto = muestra.saborNotas.categorias.length > 0
// Verificar gustos predominantes (mínimo 1, máximo 2)
const gustosCompletos = muestra.gustosPredominantes.length >= 1 && muestra.gustosPredominantes.length <= 2
return intensidadesCompletas && fraganciaAromaCompleta && saborCompleto && gustosCompletos
}
/**
* Obtiene el porcentaje de completitud de una muestra
*/
const porcentajeCompletitud = (muestra: Muestra): number => {
let total = 0
let completados = 0
// Intensidades afectivas (8 campos)
total += 8
completados += Object.values(muestra.intensidades).filter(
(intensidad) => intensidad.afectiva !== null
).length
// Intensidades descriptivas (8 campos)
total += 8
completados += Object.values(muestra.intensidades).filter(
(intensidad) => intensidad.descriptiva !== null
).length
// Notas fragancia/aroma (al menos una categoría)
total += 1
if (muestra.fraganciaAromaNotas.categorias.length > 0) completados += 1
// Notas sabor (al menos una categoría)
total += 1
if (muestra.saborNotas.categorias.length > 0) completados += 1
// Gustos predominantes
total += 1
if (muestra.gustosPredominantes.length >= 1) completados += 1
return Math.round((completados / total) * 100)
}
/**
* Elimina la sesión actual
*/
const eliminarSesionActual = async () => {
try {
await eliminar()
tabActiva.value = 'organoleptica'
accordionAbierto.value = undefined
} catch (err) {
console.error('Error al eliminar sesión:', err)
throw err
}
}
/**
* Exporta la sesión actual como JSON
*/
const exportarSesion = (): string | null => {
if (!sesionActiva.value) return null
return JSON.stringify(sesionActiva.value, null, 2)
}
/**
* Calcula estadísticas generales de la sesión
*/
const estadisticasSesion = computed(() => {
if (!sesionActiva.value) return null
const muestras = sesionActiva.value.muestras
const totalMuestras = muestras.length
const muestrasCompletas = muestras.filter(esMuestraCompleta).length
const promedioCompletitud = muestras.reduce((acc, m) => acc + porcentajeCompletitud(m), 0) / totalMuestras
const puntajePromedio = muestras.reduce((acc, m) => acc + m.puntajeFinal, 0) / totalMuestras
return {
totalMuestras,
muestrasCompletas,
porcentajeCompletitud: Math.round(promedioCompletitud),
puntajePromedio: Math.round(puntajePromedio),
}
})
return {
// Estado de la sesión
sesionActiva,
cargando,
error,
// Estado de la UI
tabActiva,
subcategoriasActivas,
accordionAbierto,
actualizarSubcategorias,
// Estadísticas
estadisticasSesion,
// Métodos CRUD de sesión
crearNuevaSesion,
eliminarSesionActual,
exportarSesion,
// Métodos de actualización de muestras
actualizarMuestra,
actualizarIntensidad,
actualizarNombreMuestra,
actualizarFraganciaAroma,
actualizarSabor,
actualizarTazasNoUniformes,
actualizarTazasDefectuosas,
actualizarDefecto,
actualizarSensacionBoca,
actualizarGustosPredominantes,
actualizarOtrasNotas,
// Utilidades
obtenerMuestra,
esMuestraCompleta,
porcentajeCompletitud,
}
}