diff --git a/CHANGELOG.md b/CHANGELOG.md index a23eb76..760a2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/). Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/). +## [1.31.1] - 2025-12-14 + +### Added +- **CategorySelector Colapsável** - Novo componente para seleção de categorias + - Categorias exibidas colapsadas por padrão + - Expande subcategorias ao clicar na categoria pai (chevron ou nome) + - Campo de busca integrado para filtrar categorias + - Usa `position: fixed` para evitar corte por `overflow: hidden` + - Badge com quantidade de subcategorias + - Mostra caminho completo da categoria selecionada (ex: "Alimentação → Supermercado") + +### Changed +- **Layout de Filtros** - Reorganizado em 3 linhas harmônicas + - Linha 1: Busca (50%) + Data Inicial (25%) + Data Final (25%) + - Linha 2: Conta + Categoria + Centro de Custo + Tipo + Estado + Limpar + - Linha 3: Botão "Categorizar em Lote" alinhado à direita + +- **CategorySelector aplicado em**: + - Modal de Editar Transação + - Filtros de Transações + - Modal de Categorização Rápida + +### Fixed +- **Dropdown cortado** - Corrigido problema do dropdown sendo cortado por containers com overflow:hidden + + ## [1.31.0] - 2025-12-14 ### Added diff --git a/VERSION b/VERSION index 34aae15..6bae540 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.31.0 +1.31.1 diff --git a/frontend/deploy.sh b/frontend/deploy.sh old mode 100644 new mode 100755 diff --git a/frontend/src/components/CategorySelector.jsx b/frontend/src/components/CategorySelector.jsx new file mode 100644 index 0000000..32e3b96 --- /dev/null +++ b/frontend/src/components/CategorySelector.jsx @@ -0,0 +1,308 @@ +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 }); + 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(); + setDropdownPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width + }); + } + }; + + // 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 ( +