Hooks
hooks/README.md
Hooks
Custom hooks que encapsulan lógica de negocio, estado de UI y efectos secundarios. Los componentes no contienen lógica propia: toda interacción con servicios, estado local complejo o side-effects vive en un hook dedicado.
Descripción General
Los hooks se organizan por dominio en subcarpetas. Siguen tres convenciones estrictas:
- Llevan la directiva
'use client'al inicio del archivo. - No declaran
interfacenitypepropios: todos los tipos se importan de@/types/<dominio>. - Importan
useState,useEffect,useCallback, etc. directamente desde'react'(no* as React).
Estructura de Archivos
hooks/
├── auth/
│ ├── useAuthListener.ts # Escucha cambios de sesión Firebase → Zustand
│ └── useLoginForm.ts # Estado y validación del formulario de login
├── categories/
│ ├── use-categories-page.ts # CRUD completo de la página de categorías
│ ├── use-card-subcategory.ts # Estado y handlers del componente CardSubcategory
│ ├── use-collapsable-category.ts # Estado y handlers del componente CollapsableCategory
│ ├── use-dialog-create-group.ts # Estado del dialog de creación de rubro
│ ├── use-dialog-create-category.ts # Estado del dialog de creación de categoría
│ ├── use-dialog-create-subcategory.ts # Estado del dialog de creación de subcategoría
│ └── use-dialog-delete-subcategory.ts # Estado del dialog de eliminación de subcategoría
├── materials/
│ ├── use-material-catalog-page.ts # Hook principal: CRUD + filtros + búsqueda
│ ├── use-dialog-create-material.ts # Estado del dialog de creación de material
│ ├── use-dialog-edit-material.ts # Estado del dialog de edición de material
│ ├── use-material-detail.ts # Suscripción + CRUD para la vista de detalle
│ └── imports/
│ └── use-material-import-page.ts # Flujo multi-paso de importación Excel
└── shared/
├── use-mobile.ts # Detecta si el viewport es mobile (< 768px)
└── use-mutation-wrapper.ts # Wrapper de mutaciones con toast + isMutating
🔐 hooks/auth
useAuthListener()
Archivo: hooks/auth/useAuthListener.ts
Escucha el estado de autenticación de Firebase en tiempo real y sincroniza el usuario con el store de Zustand.
| Parámetro | Tipo | Descripción |
|---|---|---|
| — | — | Sin parámetros |
Comportamiento:
- Suscribe a
subscribeToAuthChangesal montar. - Si hay usuario Firebase, busca su perfil en Firestore con
getUserByUid. - Llama a
setUser(user)osetUser(null)en el store. - Desuscribe al desmontar.
useLoginForm()
Archivo: hooks/auth/useLoginForm.ts
Gestiona el estado y envío del formulario de login.
🗂️ hooks/categories
useCategoriesPage()
Archivo: hooks/categories/use-categories-page.ts
Retorna: IUseCategoriesPageReturn (de @/types/categories)
Hook principal de la página /categories. Carga el catálogo completo, gestiona el filtro de búsqueda y expone todos los handlers CRUD.
Estado interno:
| State | Tipo | Descripción |
|---|---|---|
groups | IGroupCatalogView[] | Vista completa del catálogo |
isLoading | boolean | Indicador de carga |
error | Error | null | Error de carga del catálogo |
search | string | Término de búsqueda |
createGroupOpen | boolean | Controla dialog de creación de rubro |
createCatCtx | ICreateCategoryCtx | null | Contexto para crear categoría |
createSubCtx | ICreateSubcategoryCtx | null | Contexto para crear subcategoría |
deleteSubCtx | IDeleteSubcategoryCtx | null | Contexto para el flow de eliminación con materiales |
Flujo de datos:
useCategoriesPage()
└── loadCatalog()
└── getCategoryCatalog() → services/categories/category-catalog.ts
└── [getAllGroups, getAllCategories, getAllSubcategories] en paralelo
└── buildGroupCatalogViews() → utils/categories/build-catalog.ts
└── setGroups(IGroupCatalogView[])
Todos los handlers tienen try/catch con toast.error en caso de error y toast.success en caso de éxito.
useMaterialCounts({ catalog })
Archivo: hooks/categories/use-material-counts.ts
Helper: helpers/material-counts-helpers.ts
Servicios: services/materials/material-categorization.ts
🎯 Problema que resuelve
El UI del módulo de categorías muestra materialsCount en subcategorías. Antes de este cambio, el valor era un placeholder (se rellenaba con 0), por lo que el badge y los flujos de eliminación podían comportarse incorrectamente.
✅ Solución implementada
useMaterialCounts() calcula, para el catálogo recibido, los conteos de materiales por:
- Subcategoría (
subcategoryId) - Categoría (
categoryId) - Rubro/Grupo (
groupId)
La lógica se divide así:
-
Hook:
useMaterialCounts({ catalog })- Construye una firma (signature) estable a partir de los IDs del catálogo para re-ejecutar solo cuando cambie el conjunto.
- Actualiza
subcategoryCounts,categoryCountsygroupCounts. - Mantiene
countsError(primer error) y exponeisLoadingCounts.
-
Helper:
fetchMaterialCountsForCatalog(catalog, fns)- Deduplica IDs con
Set. - Ejecuta todas las consultas en paralelo con
Promise.all. - Si una consulta falla, asigna
0solo a ese nodo (fallback parcial) y devuelve el resto.
- Deduplica IDs con
-
Servicios:
services/materials/material-categorization.tsgetMaterialCountByGroup(groupId)getMaterialCountByCategory(categoryId)getMaterialCountBySubcategory(subcategoryId)
Estos servicios usan agregación Firestore vía
getCollectionCount(que internamente llama agetCountFromServer), evitando descargar documentos.
🔌 Dónde se integra
-
Página de categorías:
hooks/categories/use-categories-page.ts- Llama a
useMaterialCounts({ catalog: categoryCatalog }). - Inyecta los conteos en
buildGroupCatalogViews(...)para queISubcategoryView.materialsCountsea real. handleDeleteSubcategoryusa el conteo para decidir entre eliminar o mostrar el dialog con materiales asignados.
- Llama a
-
Construcción de la vista del catálogo:
utils/categories/build-catalog.ts- Asigna
materialsCount: subcategoryCounts?.[sub.id] ?? 0al mapear subcategorías.
- Asigna
🧩 Tipos agregados
En types/categories/index.ts se incorporaron:
IUseMaterialCountsParamsIUseMaterialCountsReturn
⚡ Consideraciones de performance y errores
- Performance: consultas en paralelo con
Promise.allpara todos los IDs (grupo/categoría/subcategoría). - Manejo de errores:
countsErrorse conserva con el primer error.- Los nodos que fallan se convierten a
0. - Si el hook falla completo, se aplican mapas vacíos (
{}), lo que termina en0por defecto en la UI.
🗂️ Archivos creados/modificados
- Creados:
hooks/categories/use-material-counts.tshelpers/material-counts-helpers.tshooks/categories/tests/use-material-counts.test.ts
- Modificados / Integrados:
services/materials/material-categorization.tshooks/categories/use-categories-page.tsutils/categories/build-catalog.tstypes/categories/index.ts- (Docs)
utils/README.md
🔎 Cómo validar
Ejecuta:
pnpm lint
pnpm type-check
pnpm test
pnpm build
Hooks por Componente
Cada componente de components/categories/ tiene un hook dedicado que extrae su estado y handlers.
| Hook | Componente | Tipos importados |
|---|---|---|
useCardSubcategory | CardSubcategory | IUseCardSubcategoryParams, IUseCardSubcategoryReturn |
useCollapsableCategory | CollapsableCategory | IUseCollapsableCategoryParams, IUseCollapsableCategoryReturn |
useDialogCreateGroup | DialogCreateGroup | IUseDialogCreateGroupParams, IUseDialogCreateGroupReturn |
useDialogCreateCategory | DialogCreateCategory | IUseDialogCreateCategoryParams, IUseDialogCreateCategoryReturn |
useDialogCreateSubcategory | DialogCreateSubcategory | IUseDialogCreateSubcategoryParams, IUseDialogCreateSubcategoryReturn |
useDialogDeleteSubcategory | DialogDeleteSubcategory | IUseDialogDeleteSubcategoryParams, IUseDialogDeleteSubcategoryReturn |
Todos estos hooks siguen el mismo patrón:
'use client';
import { useState } from 'react';
import type { IUseXxxParams, IUseXxxReturn } from '@/types/categories';
export function useXxx({ onOpenChange, onConfirm }: IUseXxxParams): IUseXxxReturn {
const [name, setName] = useState('');
const handleOpenChange = (value: boolean): void => {
if (!value) setName('');
onOpenChange(value);
};
const handleConfirm = (): void => {
onConfirm?.({ name });
handleOpenChange(false);
};
return { name, setName, handleOpenChange, handleConfirm };
}
� hooks/materials
useMaterialCatalogPage()
Archivo: hooks/materials/use-material-catalog-page.ts
Retorna: IUseMaterialCatalogPageReturn (de @/types/materials)
Hook principal de la página /material-catalog. Carga materiales y catálogo de categorías, gestiona filtros, búsqueda y todos los handlers CRUD.
Estado interno:
| State | Tipo | Descripción |
|---|---|---|
materials | IMaterial[] | Materiales crudos de Firestore |
categoryCatalog | ICategoryCatalog[] | Catálogo completo (grupos + categorías + subs) |
isLoading | boolean | Indicador de carga inicial |
search | string | Término de búsqueda |
filters | IMaterialFilters | Filtros activos (groupId, categoryId, subcategoryId) |
createOpen | boolean | Controla dialog de creación |
editCtx | IEditMaterialCtx | null | Contexto para editar un material |
deleteCtx | IDeleteMaterialCtx | null | Contexto para confirmar eliminación |
filteredMaterials (useMemo): Aplica primero los filtros de filters y luego matchesSearch sobre el nombre. Se recalcula cuando cambian materials, filters o search.
CRUD: Todos los handlers (handleCreate, handleEdit, handleDelete) usan useMutationWrapper e invalidan la cache de Next.js con revalidateTag('materials') a través de la API.
useDialogCreateMaterial({ categoryCatalog, onOpenChange, onConfirm })
Archivo: hooks/materials/use-dialog-create-material.ts
Gestiona el estado del formulario de creación de un material. Al cambiar subcategoryId, llama automáticamente a resolveFullCategorization(subcategoryId, categoryCatalog) y actualiza de forma atómica los 7 campos del formulario: name, subcategoryId, categoryId, groupId, groupName, categoryName, subcategoryName.
Parámetros:
| Parámetro | Tipo | Descripción |
|---|---|---|
categoryCatalog | ICategoryCatalog[] | Catálogo completo para resolver nombres |
onOpenChange | (open: boolean) => void | Callback de control del dialog |
onConfirm | (data: ICreateMaterialData) => void | Callback al confirmar creación |
useDialogEditMaterial({ open, categoryCatalog, initialData, onOpenChange, onConfirm })
Archivo: hooks/materials/use-dialog-edit-material.ts
Mismo patrón que useDialogCreateMaterial, con la diferencia de que sincroniza initialData al abrir el dialog via useEffect. Al cambiar subcategoryId también resuelve la categorización completa.
Parámetros adicionales:
| Parámetro | Tipo | Descripción |
|---|---|---|
open | boolean | Estado del dialog (controla el efecto de sync) |
initialData | IEditMaterialData? | Datos pre-poblados al abrir para edición |
useMaterialDetail(id)
Archivo: hooks/materials/use-material-detail.ts
Se suscribe a un material individual por ID usando subscribeToMaterial. Expone el material actual, su categorización resuelta y handlers para editar/eliminar.
useMaterialImportPage()
Archivo: hooks/materials/imports/use-material-import-page.ts
Gestiona el flujo de importación masiva de materiales desde Excel. Tiene tres pasos:
- Upload: Lee el archivo Excel y lo envía al endpoint
/api/materials/importpara parseo. - Preview: Muestra los renglones parseados en una tabla editable. Cada fila se puede corregir antes de confirmar.
- Confirm: Para cada fila llama a
resolveFullCategorization(row.subcategoryId, catalog)y construye el payload completo, incluyendo todos los campos de categorización. Llama acreateMaterialsBatch(items)con el array completo.
El batch usa writeBatch del SDK cliente y asigna id y path a cada documento desde la referencia generada por Firestore antes de persistir.
🔧 hooks/shared
useIsMobile()
Archivo: hooks/shared/use-mobile.ts
Retorna true si el ancho del viewport es menor a 768px. Usa matchMedia con listener de cambio.
useMutationWrapper()
Archivo: hooks/shared/use-mutation-wrapper.ts
Wrapper obligatorio para todas las operaciones de mutación (crear, editar, eliminar) en hooks de página. Expone isMutating (para deshabilitar botones durante la operación) y executeMutation (que envuelve el servicio en try/catch, muestra toast.success/toast.error y llama al callback onSuccess).
const { isMutating, executeMutation } = useMutationWrapper();
const handleCreate = useCallback(
(data: ICreateMaterialData) =>
executeMutation(() => createMaterial(data), {
successMessage: 'Material creado exitosamente',
errorMessage: 'Error al crear el material',
onSuccess: reloadData,
}),
[executeMutation, reloadData],
);
Cómo Agregar un Nuevo Hook
- Crear el archivo en
hooks/<dominio>/use-nombre.ts. - Agregar directiva
'use client'al inicio. - Declarar los tipos de parámetros y retorno en
@/types/<dominio>/index.ts. - Importar todos los tipos — nunca declarar
interfaceen el archivo del hook. - Para operaciones de mutación, usar
useMutationWrapperen lugar detry/catchmanuales.
