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 ( +
+
+ + {onAddNew && ( + + )} +
+ + {isOpen && ( +
+ {/* Búsqueda */} +
+ setSearchTerm(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> +
+ +
+ + {/* Opción "Ninguna" */} + + +
+ + {/* Lista de categorías */} + {displayParents.length === 0 ? ( +
+ {t('common.noResults')} +
+ ) : ( + 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 ( +
+
+ {/* Botón expandir/colapsar */} + {hasSubcategories ? ( + + ) : ( + + )} + + {/* Categoría padre */} + +
+ + {/* Subcategorías */} + {hasSubcategories && isExpanded && ( +
+ {subcategories + .filter(sub => !searchTerm.trim() || sub.name.toLowerCase().includes(searchTerm.toLowerCase())) + .map(sub => { + const isSubSelected = parseInt(value) === sub.id; + return ( + + ); + })} +
+ )} +
+ ); + }) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3b5836e..a271088 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -59,7 +59,8 @@ "selectAll": "Select All", "deselectAll": "Deselect All", "applyToSelected": "Apply to Selected", - "batchNoSelection": "Select at least one transaction" + "batchNoSelection": "Select at least one transaction", + "noResults": "No results" }, "auth": { "login": "Login", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 168c795..60c5b34 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -59,7 +59,8 @@ "selectAll": "Seleccionar Todas", "deselectAll": "Desmarcar Todas", "applyToSelected": "Aplicar a Seleccionadas", - "batchNoSelection": "Seleccione al menos una transacción" + "batchNoSelection": "Seleccione al menos una transacción", + "noResults": "Sin resultados" }, "auth": { "login": "Iniciar Sesión", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 9a75810..04bec24 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -60,7 +60,8 @@ "selectAll": "Selecionar Todas", "deselectAll": "Desmarcar Todas", "applyToSelected": "Aplicar nas Selecionadas", - "batchNoSelection": "Selecione pelo menos uma transação" + "batchNoSelection": "Selecione pelo menos uma transação", + "noResults": "Sem resultados" }, "auth": { "login": "Entrar", diff --git a/frontend/src/pages/TransactionsByWeek.jsx b/frontend/src/pages/TransactionsByWeek.jsx index b48441a..7a769db 100644 --- a/frontend/src/pages/TransactionsByWeek.jsx +++ b/frontend/src/pages/TransactionsByWeek.jsx @@ -7,6 +7,7 @@ import Modal from '../components/Modal'; import Toast from '../components/Toast'; import CreateRecurrenceModal from '../components/CreateRecurrenceModal'; import IconSelector from '../components/IconSelector'; +import CategorySelector from '../components/CategorySelector'; export default function Transactions() { const { t, i18n } = useTranslation(); @@ -1140,72 +1141,9 @@ export default function Transactions() { {filtersExpanded && (
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
+ {/* Row 1: Busca principal */} +
+
-
-
+ + {/* Row 2: Filtros principales */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Row 3: Ações */} +
+ +
)}
@@ -1758,32 +1759,13 @@ export default function Transactions() { {/* Categoria */}
-
- - -
+
{/* Centro de Custo */} @@ -2917,30 +2899,12 @@ export default function Transactions() {
- + placeholder={t('transactions.selectCategory')} + />