v1.43.3 - Mobile: Transações + CategorySelector corrigido

This commit is contained in:
marcoitaloesp-ai 2025-12-16 12:32:15 +00:00 committed by GitHub
parent be7bed5c99
commit 9800f987df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 402 additions and 39 deletions

View File

@ -5,6 +5,37 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.43.3] - 2025-12-16
### Improved
- **Página Transações - Mobile Completo** - Lista de transações otimizada para mobile
- Cards individuais: data + badges (tipo/status) + descrição + conta + categoria + valor
- Menu dropdown com todas as ações (completar, editar, duplicar, dividir, etc.)
- Descrição original do banco visível quando diferente
- Fontes: 0.65-1rem, padding p-3, borders secundários
- Desktop mantém tabela completa
- **Paginação Mobile** - Controles otimizados
- Mobile: Mostra apenas 3 botões (anterior, atual, próxima)
- Indicador "/ total" para contexto
- Botões maiores (36px) para facilitar toque
- Desktop: Mantém visualização de até 7 páginas
### Fixed
- **CategorySelector - Scroll e Visualização** - Problemas em modais corrigidos
- Position fixed com z-index 100000 (acima de modais)
- Scroll sempre visível com scrollbar customizada (8px)
- Altura fixa garantindo funcionamento: 280px (mobile) / 350px (desktop)
- Largura mínima inteligente: 280px (mobile) / 320px (desktop)
- Ajuste automático de posição se ultrapassar viewport
- Margens de segurança: 10-20px das bordas
- Cores da scrollbar harmonizadas com tema escuro
- `-webkit-overflow-scrolling: touch` para iOS
- Modal com `overflow: auto` para scroll adequado
- Cálculo simplificado e robusto de posição
- Funciona perfeitamente em todos os lugares (modais e páginas)
## [1.43.2] - 2025-12-16
### Improved

View File

@ -1 +1 @@
1.43.2
1.43.3

View File

@ -30,24 +30,46 @@ export default function CategorySelector({
const dropdownRef = useRef(null);
const searchInputRef = useRef(null);
// Calcular posición del dropdown (arriba o abajo según espacio disponible)
// Calcular posición del dropdown
const updateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const dropdownHeight = 350; // altura aproximada del dropdown
const isMobile = window.innerWidth < 768;
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom - 10; // 10px margen
// 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 si mostrar arriba o abajo
const showAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
// 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 - Math.min(dropdownHeight, spaceAbove) : rect.bottom,
left: rect.left,
width: rect.width,
top: showAbove ? rect.top - dropdownHeight : rect.bottom,
left: leftPosition,
width: minWidth,
showAbove: showAbove,
maxHeight: showAbove ? Math.min(dropdownHeight, spaceAbove) : Math.min(dropdownHeight, spaceBelow)
maxHeight: dropdownHeight
});
}
};
@ -196,18 +218,24 @@ export default function CategorySelector({
{isOpen && (
<div
ref={dropdownRef}
className="dropdown-menu show"
className="dropdown-menu show category-dropdown-scroll"
style={{
position: 'fixed',
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
zIndex: 99999,
maxHeight: dropdownPosition.maxHeight || 350,
minHeight: '150px',
overflowY: 'auto',
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',
boxShadow: '0 6px 20px rgba(0,0,0,0.4)'
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 */}

View File

@ -66,7 +66,7 @@ const Modal = ({
className="modal show d-block"
tabIndex="-1"
onClick={handleBackdropClick}
style={{ zIndex: 1055 }}
style={{ zIndex: 1055, overflow: 'auto' }}
>
<div className={`modal-dialog ${sizeClass} ${centered ? 'modal-dialog-centered' : ''}`}>
<div className="modal-content" style={{ background: '#1e293b', color: '#f1f5f9' }}>
@ -83,7 +83,7 @@ const Modal = ({
)}
</div>
)}
<div className="modal-body">
<div className="modal-body" style={{ maxHeight: 'calc(100vh - 210px)', overflowY: 'auto' }}>
{children}
</div>
{footer && (

View File

@ -1837,6 +1837,7 @@ input[type="color"]::-webkit-color-swatch {
justify-content: center;
gap: 0.375rem;
margin-top: 1rem;
align-items: center;
}
.txn-pagination button {
@ -1869,6 +1870,23 @@ input[type="color"]::-webkit-color-swatch {
cursor: not-allowed;
}
/* Mobile pagination */
.txn-pagination.mobile {
gap: 0.25rem;
}
.txn-pagination.mobile button {
min-width: 36px;
height: 36px;
font-size: 0.8rem;
}
.txn-pagination .page-info {
color: #94a3b8;
font-size: 0.75rem;
margin-left: 0.25rem;
}
/* Empty state */
.txn-empty {
text-align: center;
@ -2822,3 +2840,41 @@ a,
min-height: 50px;
}
}
/* Category Selector - garantir que funcione em modais */
.category-selector {
position: relative;
z-index: 1;
}
.modal-body .category-selector {
overflow: visible;
}
.category-selector .dropdown-menu {
max-width: 100%;
}
/* Scroll customizado para dropdown de categoria */
.category-dropdown-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(100, 116, 139, 0.5) rgba(30, 41, 59, 0.3);
}
.category-dropdown-scroll::-webkit-scrollbar {
width: 8px;
}
.category-dropdown-scroll::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.3);
border-radius: 4px;
}
.category-dropdown-scroll::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.5);
border-radius: 4px;
}
.category-dropdown-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.7);
}

View File

@ -22,6 +22,9 @@ export default function Transactions() {
// Estado para destacar transação
const [highlightedTransactionId, setHighlightedTransactionId] = useState(null);
// Mobile detection
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
// Estados principais
const [weeklyData, setWeeklyData] = useState(null);
const [accounts, setAccounts] = useState([]);
@ -252,6 +255,13 @@ export default function Transactions() {
loadWeeklyData();
}, [filters, page, selectedCurrency]); // eslint-disable-line react-hooks/exhaustive-deps
// Mobile resize detection
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Efeito para destacar transação via URL
useEffect(() => {
const highlightId = searchParams.get('highlight');
@ -1339,6 +1349,219 @@ export default function Transactions() {
{/* Week Transactions */}
{expandedWeeks[week.year_week] && (
<div className="txn-week-body">
{isMobile ? (
// Mobile: Cards Layout
<div className="d-flex flex-column gap-2 p-2">
{week.transactions.map(transaction => (
<div
key={transaction.id}
ref={transaction.id === highlightedTransactionId ? highlightedRef : null}
className={`card border-secondary ${transaction.is_overdue ? 'border-danger' : ''} ${transaction.id === highlightedTransactionId ? 'border-primary' : ''} ${hasActiveFilters && selectedTransactionIds.has(transaction.id) ? 'bg-primary bg-opacity-10' : ''}`}
style={{ background: '#0f172a', cursor: 'pointer' }}
>
<div className="card-body p-3">
{/* Header: Date + Type Badge + Status */}
<div className="d-flex justify-content-between align-items-start mb-2">
<div className="d-flex align-items-center gap-2">
{hasActiveFilters && (
<input
type="checkbox"
className="form-check-input"
checked={selectedTransactionIds.has(transaction.id)}
onChange={() => handleToggleTransaction(transaction.id)}
onClick={(e) => e.stopPropagation()}
/>
)}
<span className="text-slate-400" style={{ fontSize: '0.75rem' }}>
{formatDate(transaction.effective_date || transaction.planned_date)}
{transaction.is_overdue && <i className="bi bi-exclamation-triangle text-danger ms-1"></i>}
</span>
</div>
<div className="d-flex gap-1">
<span className={`badge ${transaction.type === 'credit' ? 'bg-success' : 'bg-danger'} bg-opacity-25 ${transaction.type === 'credit' ? 'text-success' : 'text-danger'}`} style={{ fontSize: '0.65rem' }}>
{transaction.type === 'credit' ? t('transactions.type.credit') : t('transactions.type.debit')}
</span>
<span className={`badge ${
transaction.status === 'completed' ? 'bg-success' :
transaction.status === 'pending' ? 'bg-warning' :
transaction.status === 'cancelled' ? 'bg-secondary' : 'bg-info'
} bg-opacity-25 text-${
transaction.status === 'completed' ? 'success' :
transaction.status === 'pending' ? 'warning' :
transaction.status === 'cancelled' ? 'secondary' : 'info'
}`} style={{ fontSize: '0.65rem' }}>
{t(`transactions.status.${transaction.status}`)}
</span>
</div>
</div>
{/* Description + Transfer/Reconciled badges */}
<div className="mb-2" onClick={() => openDetailModal(transaction)}>
<div className="d-flex align-items-center gap-1 mb-1">
{transaction.is_transfer && <i className="bi bi-arrow-left-right text-info"></i>}
{transaction.is_reconciled && <i className="bi bi-link-45deg text-success" title={t('transactions.reconciled')}></i>}
<span className="text-white fw-medium" style={{ fontSize: '0.85rem' }}>
{transaction.description}
</span>
</div>
{transaction.original_description && transaction.original_description !== transaction.description && (
<div className="text-slate-400" style={{ fontSize: '0.65rem' }}>
<i className="bi bi-bank me-1"></i>
{transaction.original_description.substring(0, 60)}{transaction.original_description.length > 60 ? '...' : ''}
</div>
)}
</div>
{/* Account + Category */}
<div className="d-flex flex-wrap gap-2 mb-2">
<span className="badge bg-secondary bg-opacity-25 text-slate-300" style={{ fontSize: '0.7rem' }}>
<i className="bi bi-wallet2 me-1"></i>
{transaction.account?.name}
</span>
{transaction.category && (
<span
className="badge"
style={{
backgroundColor: transaction.category.color + '20',
color: transaction.category.color,
fontSize: '0.7rem'
}}
>
<i className={`bi ${transaction.category.icon} me-1`}></i>
{transaction.category.name}
</span>
)}
</div>
{/* Amount + Actions */}
<div className="d-flex justify-content-between align-items-center pt-2" style={{ borderTop: '1px solid #334155' }}>
<div className={`fw-bold ${transaction.type === 'credit' ? 'text-success' : 'text-danger'}`} style={{ fontSize: '1rem' }}>
{transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(transaction.amount || transaction.planned_amount, selectedCurrency)}
</div>
<div className="dropdown" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end shadow-sm">
{/* Ações de Status */}
{transaction.status === 'pending' && (
<>
<li className="dropdown-header small text-muted">{t('transactions.status.label')}</li>
<li>
<button className="dropdown-item" onClick={() => openCompleteModal(transaction)}>
<i className="bi bi-check-circle text-success me-2"></i>
{t('transactions.markComplete')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => handleQuickComplete(transaction)}>
<i className="bi bi-lightning-fill text-warning me-2"></i>
{t('transactions.quickComplete')}
</button>
</li>
<li><hr className="dropdown-divider" /></li>
</>
)}
{transaction.status === 'completed' && (
<>
<li className="dropdown-header small text-muted">{t('transactions.status.label')}</li>
<li>
<button className="dropdown-item" onClick={() => handleRevert(transaction)}>
<i className="bi bi-arrow-counterclockwise text-warning me-2"></i>
{t('transactions.revert')}
</button>
</li>
<li><hr className="dropdown-divider" /></li>
</>
)}
{/* Ações Principais */}
<li>
<button className="dropdown-item" onClick={() => openQuickCategorizeModal(transaction)}>
<i className="bi bi-tags text-success me-2"></i>
{t('transactions.quickCategorize')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => openEditModal(transaction)}>
<i className="bi bi-pencil text-primary me-2"></i>
{t('common.edit')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => handleDuplicate(transaction)}>
<i className="bi bi-copy text-info me-2"></i>
{t('transactions.duplicate')}
</button>
</li>
{/* Dividir transação */}
{!transaction.is_transfer && !transaction.split_parent_id && (
<li>
<button className="dropdown-item" onClick={() => openSplitModal(transaction)}>
<i className="bi bi-diagram-3 text-secondary me-2"></i>
{t('transactions.split')}
</button>
</li>
)}
{/* Converter em transferência */}
{!transaction.is_transfer && !transaction.is_split_child && (
<li>
<button className="dropdown-item" onClick={() => openConvertTransferModal(transaction)}>
<i className="bi bi-arrow-left-right text-purple me-2"></i>
{t('transactions.convertToTransfer')}
</button>
</li>
)}
{/* Conciliar com passivo */}
{transaction.type === 'debit' && !transaction.is_reconciled && !transaction.is_transfer && (
<li>
<button className="dropdown-item" onClick={() => openReconcileLiabilityModal(transaction)}>
<i className="bi bi-link-45deg text-purple me-2"></i>
{t('transactions.reconcileWithLiability')}
</button>
</li>
)}
{/* Criar Recorrência */}
{!transaction.is_transfer && !transaction.recurring_instance_id && (
<li>
<button className="dropdown-item" onClick={() => openRecurrenceModal(transaction)}>
<i className="bi bi-calendar-check text-info me-2"></i>
{t('recurring.makeRecurring')}
</button>
</li>
)}
{/* Cancelar */}
{transaction.status === 'pending' && (
<li>
<button className="dropdown-item" onClick={() => handleCancel(transaction)}>
<i className="bi bi-x-circle text-secondary me-2"></i>
{t('transactions.cancel')}
</button>
</li>
)}
<li><hr className="dropdown-divider" /></li>
<li>
<button className="dropdown-item text-danger" onClick={() => handleDelete(transaction)}>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
))}
</div>
) : (
// Desktop: Table Layout
<table className="txn-table">
<thead>
<tr>
@ -1562,6 +1785,7 @@ export default function Transactions() {
))}
</tbody>
</table>
)}
{/* Transferências Agrupadas */}
{week.transfers && week.transfers.length > 0 && (
@ -1666,27 +1890,51 @@ export default function Transactions() {
{/* Pagination */}
{pagination && pagination.total_pages > 1 && (
<div className="txn-pagination">
<div className={`txn-pagination ${isMobile ? 'mobile' : ''}`}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
<i className="bi bi-chevron-left"></i>
</button>
{Array.from({ length: Math.min(pagination.total_pages, 7) }, (_, i) => {
let pageNum;
if (pagination.total_pages <= 7) {
pageNum = i + 1;
} else if (page <= 4) {
pageNum = i + 1;
} else if (page >= pagination.total_pages - 3) {
pageNum = pagination.total_pages - 6 + i;
} else {
pageNum = page - 3 + i;
}
return (
<button key={pageNum} className={page === pageNum ? 'active' : ''} onClick={() => setPage(pageNum)}>
{pageNum}
{isMobile ? (
// Mobile: Mostrar apenas 3 páginas (anterior, atual, próxima)
<>
{page > 1 && (
<button onClick={() => setPage(page - 1)}>
{page - 1}
</button>
)}
<button className="active">
{page}
</button>
);
})}
{page < pagination.total_pages && (
<button onClick={() => setPage(page + 1)}>
{page + 1}
</button>
)}
{/* Info de página */}
<span className="page-info">
/ {pagination.total_pages}
</span>
</>
) : (
// Desktop: Mostrar até 7 páginas
Array.from({ length: Math.min(pagination.total_pages, 7) }, (_, i) => {
let pageNum;
if (pagination.total_pages <= 7) {
pageNum = i + 1;
} else if (page <= 4) {
pageNum = i + 1;
} else if (page >= pagination.total_pages - 3) {
pageNum = pagination.total_pages - 6 + i;
} else {
pageNum = page - 3 + i;
}
return (
<button key={pageNum} className={page === pageNum ? 'active' : ''} onClick={() => setPage(pageNum)}>
{pageNum}
</button>
);
})
)}
<button onClick={() => setPage(p => Math.min(pagination.total_pages, p + 1))} disabled={page === pagination.total_pages}>
<i className="bi bi-chevron-right"></i>
</button>