347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
/**
|
|
* CategorySelector - Selector de categorías colapsable
|
|
*
|
|
* Props:
|
|
* - categories: Array de todas las categorías
|
|
* - value: ID de la categoría seleccionada
|
|
* - onChange: Función callback cuando se selecciona una categoría
|
|
* - onAddNew: Función callback para agregar nueva categoría (opcional)
|
|
* - placeholder: Texto del placeholder (opcional)
|
|
* - disabled: Si está deshabilitado (opcional)
|
|
*/
|
|
export default function CategorySelector({
|
|
categories = [],
|
|
value,
|
|
onChange,
|
|
onAddNew,
|
|
placeholder,
|
|
disabled = false
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [expandedCategories, setExpandedCategories] = useState({});
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0, showAbove: false });
|
|
const containerRef = useRef(null);
|
|
const buttonRef = useRef(null);
|
|
const dropdownRef = useRef(null);
|
|
const searchInputRef = useRef(null);
|
|
|
|
// Calcular posición del dropdown
|
|
const updateDropdownPosition = () => {
|
|
if (buttonRef.current) {
|
|
const rect = buttonRef.current.getBoundingClientRect();
|
|
const isMobile = window.innerWidth < 768;
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
// Altura máxima do dropdown
|
|
const maxDropdownHeight = isMobile ? 280 : 350;
|
|
|
|
// Largura mínima para exibir conteúdo completo
|
|
const minWidth = Math.max(rect.width, isMobile ? 280 : 320);
|
|
|
|
// Ajustar left se ultrapassar viewport
|
|
let leftPosition = rect.left;
|
|
const viewportWidth = window.innerWidth;
|
|
if (leftPosition + minWidth > viewportWidth - 20) {
|
|
leftPosition = viewportWidth - minWidth - 20;
|
|
}
|
|
if (leftPosition < 10) {
|
|
leftPosition = 10;
|
|
}
|
|
|
|
// Espaço disponível abaixo e acima
|
|
const spaceBelow = viewportHeight - rect.bottom - 10;
|
|
const spaceAbove = rect.top - 10;
|
|
|
|
// Decidir se mostra acima ou abaixo
|
|
const showAbove = spaceBelow < maxDropdownHeight && spaceAbove > spaceBelow;
|
|
|
|
// Calcular altura real disponível
|
|
const availableSpace = showAbove ? spaceAbove : spaceBelow;
|
|
const dropdownHeight = Math.min(maxDropdownHeight, Math.max(200, availableSpace));
|
|
|
|
setDropdownPosition({
|
|
top: showAbove ? rect.top - dropdownHeight : rect.bottom,
|
|
left: leftPosition,
|
|
width: minWidth,
|
|
showAbove: showAbove,
|
|
maxHeight: dropdownHeight
|
|
});
|
|
}
|
|
};
|
|
|
|
// Cerrar dropdown al hacer clic fuera
|
|
useEffect(() => {
|
|
const handleClickOutside = (event) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target) &&
|
|
dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
|
setIsOpen(false);
|
|
setSearchTerm('');
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Actualizar posición cuando se abre y en scroll/resize
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
updateDropdownPosition();
|
|
window.addEventListener('scroll', updateDropdownPosition, true);
|
|
window.addEventListener('resize', updateDropdownPosition);
|
|
return () => {
|
|
window.removeEventListener('scroll', updateDropdownPosition, true);
|
|
window.removeEventListener('resize', updateDropdownPosition);
|
|
};
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Focus en búsqueda al abrir
|
|
useEffect(() => {
|
|
if (isOpen && searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Obtener categorías padre (sin parent_id)
|
|
const parentCategories = Array.isArray(categories) ? categories.filter(c => !c.parent_id) : [];
|
|
|
|
// Obtener subcategorías de una categoría padre
|
|
const getSubcategories = (parentId) => {
|
|
if (!categories || !Array.isArray(categories)) return [];
|
|
return categories.filter(c => c.parent_id === parentId);
|
|
};
|
|
|
|
// Obtener categoría por ID
|
|
const getCategoryById = (id) => {
|
|
if (!id) return null;
|
|
return categories.find(c => c.id === parseInt(id));
|
|
};
|
|
|
|
// Obtener nombre completo de la categoría seleccionada
|
|
const getSelectedCategoryName = () => {
|
|
const category = getCategoryById(value);
|
|
if (!category) return placeholder || t('common.none');
|
|
|
|
if (category.parent_id) {
|
|
const parent = getCategoryById(category.parent_id);
|
|
return parent ? `${parent.name} → ${category.name}` : category.name;
|
|
}
|
|
return category.name;
|
|
};
|
|
|
|
// Toggle expansión de categoría
|
|
const toggleCategory = (categoryId, e) => {
|
|
e.stopPropagation();
|
|
setExpandedCategories(prev => ({
|
|
...prev,
|
|
[categoryId]: !prev[categoryId]
|
|
}));
|
|
};
|
|
|
|
// Seleccionar categoría
|
|
const handleSelect = (categoryId, e) => {
|
|
e.stopPropagation();
|
|
onChange({ target: { name: 'category_id', value: categoryId || '' } });
|
|
setIsOpen(false);
|
|
setSearchTerm('');
|
|
};
|
|
|
|
// Filtrar categorías por búsqueda
|
|
const filterCategories = () => {
|
|
if (!searchTerm.trim()) return parentCategories;
|
|
|
|
const term = searchTerm.toLowerCase();
|
|
const matchingParents = [];
|
|
const matchingSubcategories = new Set();
|
|
|
|
parentCategories.forEach(parent => {
|
|
const parentMatches = parent.name.toLowerCase().includes(term);
|
|
const subs = getSubcategories(parent.id);
|
|
const matchingSubs = subs.filter(sub => sub.name.toLowerCase().includes(term));
|
|
|
|
if (parentMatches || matchingSubs.length > 0) {
|
|
matchingParents.push(parent);
|
|
if (matchingSubs.length > 0) {
|
|
matchingSubcategories.add(parent.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
return { parents: matchingParents, expanded: matchingSubcategories };
|
|
};
|
|
|
|
const filteredData = searchTerm.trim() ? filterCategories() : { parents: parentCategories, expanded: new Set() };
|
|
const displayParents = filteredData.parents || parentCategories;
|
|
const autoExpanded = filteredData.expanded || new Set();
|
|
|
|
return (
|
|
<div className="category-selector" ref={containerRef} style={{ position: 'relative' }}>
|
|
<div className="input-group" ref={buttonRef}>
|
|
<button
|
|
type="button"
|
|
className="form-select text-start"
|
|
onClick={() => {
|
|
if (!disabled) {
|
|
updateDropdownPosition();
|
|
setIsOpen(!isOpen);
|
|
}
|
|
}}
|
|
disabled={disabled}
|
|
style={{
|
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
backgroundColor: disabled ? '#e9ecef' : undefined,
|
|
padding: '0.375rem 2.25rem 0.375rem 0.75rem'
|
|
}}
|
|
>
|
|
<span className={!value ? 'text-muted' : ''} style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{getSelectedCategoryName()}
|
|
</span>
|
|
</button>
|
|
{onAddNew && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-primary"
|
|
onClick={onAddNew}
|
|
title={t('categories.newCategory')}
|
|
>
|
|
<i className="bi bi-plus-lg"></i>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div
|
|
ref={dropdownRef}
|
|
className="dropdown-menu show category-dropdown-scroll"
|
|
style={{
|
|
position: 'fixed',
|
|
top: `${dropdownPosition.top}px`,
|
|
left: `${dropdownPosition.left}px`,
|
|
width: `${dropdownPosition.width}px`,
|
|
zIndex: 100000,
|
|
height: `${dropdownPosition.maxHeight || 280}px`,
|
|
maxHeight: `${dropdownPosition.maxHeight || 280}px`,
|
|
minHeight: '200px',
|
|
overflowY: 'scroll',
|
|
overflowX: 'hidden',
|
|
padding: '0.5rem 0',
|
|
margin: 0,
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
WebkitOverflowScrolling: 'touch',
|
|
display: 'block'
|
|
}}
|
|
>
|
|
{/* Búsqueda */}
|
|
<div className="px-3 pb-2">
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
className="form-control form-control-sm"
|
|
placeholder={t('common.search')}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
|
|
<div className="dropdown-divider my-1"></div>
|
|
|
|
{/* Opción "Ninguna" */}
|
|
<button
|
|
type="button"
|
|
className={`dropdown-item ${!value ? 'active' : ''}`}
|
|
onClick={(e) => handleSelect(null, e)}
|
|
>
|
|
<span className="text-muted">{t('common.none')}</span>
|
|
</button>
|
|
|
|
<div className="dropdown-divider my-1"></div>
|
|
|
|
{/* Lista de categorías */}
|
|
{displayParents.length === 0 ? (
|
|
<div className="px-3 py-2 text-muted text-center">
|
|
<small>{t('common.noResults')}</small>
|
|
</div>
|
|
) : (
|
|
displayParents.map(parent => {
|
|
const subcategories = getSubcategories(parent.id);
|
|
const hasSubcategories = subcategories.length > 0;
|
|
const isExpanded = expandedCategories[parent.id] || autoExpanded.has(parent.id);
|
|
const isSelected = parseInt(value) === parent.id;
|
|
|
|
return (
|
|
<div key={parent.id}>
|
|
<div
|
|
className="d-flex align-items-center"
|
|
style={{ padding: '0.25rem 0.5rem' }}
|
|
>
|
|
{/* Botón expandir/colapsar */}
|
|
{hasSubcategories ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-link p-0 me-1"
|
|
onClick={(e) => toggleCategory(parent.id, e)}
|
|
style={{ width: '20px', color: '#6c757d' }}
|
|
>
|
|
<i className={`bi bi-chevron-${isExpanded ? 'down' : 'right'}`}></i>
|
|
</button>
|
|
) : (
|
|
<span style={{ width: '20px', display: 'inline-block' }}></span>
|
|
)}
|
|
|
|
{/* Categoría padre */}
|
|
<button
|
|
type="button"
|
|
className={`dropdown-item flex-grow-1 rounded ${isSelected ? 'active' : ''}`}
|
|
onClick={(e) => handleSelect(parent.id, e)}
|
|
style={{ padding: '0.35rem 0.5rem' }}
|
|
>
|
|
{parent.icon && <i className={`bi ${parent.icon} me-2`}></i>}
|
|
<span style={{ fontWeight: '500' }}>{parent.name}</span>
|
|
{hasSubcategories && (
|
|
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.7rem' }}>
|
|
{subcategories.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Subcategorías */}
|
|
{hasSubcategories && isExpanded && (
|
|
<div style={{ marginLeft: '20px', borderLeft: '2px solid #dee2e6', marginBottom: '0.25rem' }}>
|
|
{subcategories
|
|
.filter(sub => !searchTerm.trim() || sub.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
.map(sub => {
|
|
const isSubSelected = parseInt(value) === sub.id;
|
|
return (
|
|
<button
|
|
key={sub.id}
|
|
type="button"
|
|
className={`dropdown-item ${isSubSelected ? 'active' : ''}`}
|
|
onClick={(e) => handleSelect(sub.id, e)}
|
|
style={{ paddingLeft: '1rem', fontSize: '0.9rem' }}
|
|
>
|
|
{sub.icon && <i className={`bi ${sub.icon} me-2`}></i>}
|
|
{sub.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|