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:
parent
c31195b24f
commit
1c864463d6
26
CHANGELOG.md
26
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
|
||||
|
||||
0
frontend/deploy.sh
Normal file → Executable file
0
frontend/deploy.sh
Normal file → Executable file
308
frontend/src/components/CategorySelector.jsx
Normal file
308
frontend/src/components/CategorySelector.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user