feat: CategorySelector colapsable - selectores de categoría com dropdown colapsado

- Novo componente CategorySelector com categorias colapsadas por padrão
- Expande subcategorias ao clicar na categoria pai
- Busca integrada para filtrar categorias
- Usa position:fixed para evitar corte por overflow:hidden
- Aplicado em: modal de edição, filtros e modal de categorizar
- Layout de filtros reorganizado em 3 linhas harmônicas
- Traduções: common.noResults em ES, PT-BR, EN

v1.31.1
This commit is contained in:
marcoitaloesp-ai 2025-12-14 14:01:44 +00:00 committed by GitHub
parent c31195b24f
commit 1c864463d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 427 additions and 126 deletions

View File

@ -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

View File

@ -1 +1 @@
1.31.0
1.31.1

0
frontend/deploy.sh Normal file → Executable file
View File

View File

@ -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 (
<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"
style={{
position: 'fixed',
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
zIndex: 99999,
maxHeight: '400px',
minHeight: '200px',
overflowY: 'auto',
padding: '0.5rem 0',
boxShadow: '0 6px 20px rgba(0,0,0,0.4)'
}}
>
{/* 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>
);
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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() {
</div>
{filtersExpanded && (
<div className="txn-filters-body">
<div className="txn-filters-grid">
<div>
<label className="form-label">{t('transactions.account')}</label>
<select className="form-select" name="account_id" value={filters.account_id} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
{accounts.map(account => (
<option key={account.id} value={account.id}>{account.name}</option>
))}
</select>
</div>
<div>
<label className="form-label">{t('transactions.category')}</label>
<select className="form-select" name="category_id" value={filters.category_id} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
{parentCategories.map(cat => (
<optgroup key={cat.id} label={cat.name}>
<option value={cat.id}>{cat.name}</option>
{getSubcategories(cat.id).map(sub => (
<option key={sub.id} value={sub.id}> {sub.name}</option>
))}
</optgroup>
))}
</select>
</div>
<div>
<label className="form-label">{t('transactions.type.label')}</label>
<select className="form-select" name="type" value={filters.type} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
<option value="credit">{t('transactions.type.credit')}</option>
<option value="debit">{t('transactions.type.debit')}</option>
</select>
</div>
<div>
<label className="form-label">{t('transactions.status.label')}</label>
<select className="form-select" name="status" value={filters.status} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
<option value="pending">{t('transactions.status.pending')}</option>
<option value="completed">{t('transactions.status.completed')}</option>
<option value="cancelled">{t('transactions.status.cancelled')}</option>
</select>
</div>
<div>
<label className="form-label">{t('transactions.startDate')}</label>
<input type="date" className="form-control" name="start_date" value={filters.start_date} onChange={handleFilterChange} />
</div>
<div>
<label className="form-label">{t('transactions.endDate')}</label>
<input type="date" className="form-control" name="end_date" value={filters.end_date} onChange={handleFilterChange} />
</div>
<div>
<label className="form-label">{t('transactions.costCenter')}</label>
<select className="form-select" name="cost_center_id" value={filters.cost_center_id} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
</div>
<div>
<button className="btn btn-outline-secondary btn-sm" onClick={clearFilters} style={{ marginTop: '1.5rem' }}>
<i className="bi bi-x-lg me-1"></i>{t('common.clearFilters')}
</button>
</div>
</div>
<div className="mt-3 d-flex align-items-end gap-3 flex-wrap">
<div style={{ flex: '1', minWidth: '250px', maxWidth: '400px' }}>
{/* Row 1: Busca principal */}
<div className="row g-3 mb-3">
<div className="col-12 col-md-6">
<label className="form-label">{t('common.search')}</label>
<input
type="text"
@ -1216,17 +1154,80 @@ export default function Transactions() {
placeholder={t('transactions.searchPlaceholder')}
/>
</div>
<div>
<button
className="btn btn-warning btn-sm"
onClick={handleOpenBatchModal}
title={t('categories.batchCategorize')}
>
<i className="bi bi-lightning-charge me-1"></i>
{t('categories.batchCategorize')}
<div className="col-6 col-md-3">
<label className="form-label">{t('transactions.startDate')}</label>
<input type="date" className="form-control" name="start_date" value={filters.start_date} onChange={handleFilterChange} />
</div>
<div className="col-6 col-md-3">
<label className="form-label">{t('transactions.endDate')}</label>
<input type="date" className="form-control" name="end_date" value={filters.end_date} onChange={handleFilterChange} />
</div>
</div>
{/* Row 2: Filtros principales */}
<div className="row g-3 mb-3">
<div className="col-6 col-md-2">
<label className="form-label">{t('transactions.account')}</label>
<select className="form-select" name="account_id" value={filters.account_id} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
{accounts.map(account => (
<option key={account.id} value={account.id}>{account.name}</option>
))}
</select>
</div>
<div className="col-6 col-md-3">
<label className="form-label">{t('transactions.category')}</label>
<CategorySelector
categories={categories}
value={filters.category_id}
onChange={handleFilterChange}
placeholder={t('common.all')}
/>
</div>
<div className="col-6 col-md-2">
<label className="form-label">{t('transactions.costCenter')}</label>
<select className="form-select" name="cost_center_id" value={filters.cost_center_id} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
</div>
<div className="col-6 col-md-2">
<label className="form-label">{t('transactions.type.label')}</label>
<select className="form-select" name="type" value={filters.type} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
<option value="credit">{t('transactions.type.credit')}</option>
<option value="debit">{t('transactions.type.debit')}</option>
</select>
</div>
<div className="col-6 col-md-2">
<label className="form-label">{t('transactions.status.label')}</label>
<select className="form-select" name="status" value={filters.status} onChange={handleFilterChange}>
<option value="">{t('common.all')}</option>
<option value="pending">{t('transactions.status.pending')}</option>
<option value="completed">{t('transactions.status.completed')}</option>
<option value="cancelled">{t('transactions.status.cancelled')}</option>
</select>
</div>
<div className="col-6 col-md-1 d-flex align-items-end">
<button className="btn btn-outline-secondary w-100" onClick={clearFilters} title={t('common.clearFilters')}>
<i className="bi bi-x-lg"></i>
</button>
</div>
</div>
{/* Row 3: Ações */}
<div className="d-flex justify-content-end">
<button
className="btn btn-warning"
onClick={handleOpenBatchModal}
title={t('categories.batchCategorize')}
>
<i className="bi bi-lightning-charge me-1"></i>
{t('categories.batchCategorize')}
</button>
</div>
</div>
)}
</div>
@ -1758,32 +1759,13 @@ export default function Transactions() {
{/* Categoria */}
<div className="col-md-6">
<label className="form-label">{t('transactions.category')}</label>
<div className="input-group">
<select
className="form-select"
name="category_id"
value={formData.category_id}
onChange={handleInputChange}
>
<option value="">{t('common.none')}</option>
{parentCategories.map(cat => (
<optgroup key={cat.id} label={cat.name}>
<option value={cat.id}>{cat.name}</option>
{getSubcategories(cat.id).map(sub => (
<option key={sub.id} value={sub.id}> {sub.name}</option>
))}
</optgroup>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCategoryModal}
title={t('categories.newCategory')}
>
<i className="bi bi-plus-lg"></i>
</button>
</div>
<CategorySelector
categories={categories}
value={formData.category_id}
onChange={handleInputChange}
onAddNew={openQuickCategoryModal}
placeholder={t('common.none')}
/>
</div>
{/* Centro de Custo */}
@ -2917,30 +2899,12 @@ export default function Transactions() {
<div className="mb-3">
<label className="form-label">{t('transactions.category')}</label>
<select
className="form-select"
<CategorySelector
categories={categories}
value={quickCategorizeData.category_id}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, category_id: e.target.value }))}
>
<option value="">{t('transactions.selectCategory')}</option>
{(() => {
// Agrupar categorias por parent
const parentCategories = categories.filter(c => !c.parent_id);
const childCategories = categories.filter(c => c.parent_id);
return parentCategories.map(parent => (
<optgroup key={parent.id} label={parent.name}>
<option value={parent.id}>{parent.name}</option>
{childCategories
.filter(child => child.parent_id === parent.id)
.map(child => (
<option key={child.id} value={child.id}> {child.name}</option>
))
}
</optgroup>
));
})()}
</select>
placeholder={t('transactions.selectCategory')}
/>
</div>
<div className="mb-3">