webmoney/frontend/src/components/CategorySelector.jsx

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>
);
}