feat: melhorias no filtro de transações

- Filtro 'Sem Categoria' mostra lista flat ao invés de agrupada por semanas
- Transações futuras aparecem quando qualquer filtro está ativo
- Filtro de data parcial (só data inicial ou só data final)
- COALESCE para effective_date com fallback para planned_date
- Traduções i18n para filteredResults, filterActive, uncategorized
This commit is contained in:
marco 2025-12-19 14:28:27 +01:00
parent f2e032f002
commit 44bc999840
6 changed files with 310 additions and 13 deletions

View File

@ -4,7 +4,7 @@
**IMPORTANTE:** Estamos trabalhando diretamente no servidor de produção. **IMPORTANTE:** Estamos trabalhando diretamente no servidor de produção.
- **Hostname:** mail.cnxifly.com - **Hostname:** cnxifly.com
- **IP:** 213.165.93.60 - **IP:** 213.165.93.60
- **Sistema:** Ubuntu 24.04.3 LTS - **Sistema:** Ubuntu 24.04.3 LTS
- **Workspace:** `/root/webmoney` (symlink: `/var/www/webmoney`) - **Workspace:** `/root/webmoney` (symlink: `/var/www/webmoney`)
@ -55,7 +55,7 @@ cd /root/webmoney/backend && php artisan config:clear && php artisan cache:clear
cd /root/webmoney cd /root/webmoney
git add -A && git commit -m "descrição" && git push origin main git add -A && git commit -m "descrição" && git push origin main
# 6. Atualizar VERSION e CHANGELOG.md se necessário # 6. Atualizar VERSION, README, APRENDIZEDOS_TECNICOS e CHANGELOG.md se necessário
``` ```
### ❌ Proibições Git ### ❌ Proibições Git

View File

@ -393,7 +393,12 @@ public function duplicate(Request $request, Transaction $transaction): JsonRespo
public function byWeek(Request $request): JsonResponse public function byWeek(Request $request): JsonResponse
{ {
$userId = $request->user()->id; $userId = $request->user()->id;
$perPage = $request->get('per_page', 10); // Semanas por página
// Verificar se há filtros ativos (além de date_field e currency)
$hasActiveFilters = $request->hasAny(['account_id', 'category_id', 'cost_center_id', 'type', 'status', 'search', 'start_date', 'end_date']);
// Se há filtros, trazer mais semanas para mostrar todos os resultados
$perPage = $hasActiveFilters ? 100 : $request->get('per_page', 10); // Mais semanas quando filtrado
$page = $request->get('page', 1); $page = $request->get('page', 1);
$currency = $request->get('currency'); // Filtro de divisa opcional $currency = $request->get('currency'); // Filtro de divisa opcional
$dateField = $request->get('date_field', 'planned_date'); $dateField = $request->get('date_field', 'planned_date');
@ -432,11 +437,41 @@ public function byWeek(Request $request): JsonResponse
$query->where('status', $request->status); $query->where('status', $request->status);
} }
// Filtro por período // Filtro por período - aceita filtros parciais
if ($request->has('start_date') && $request->has('end_date')) { $hasDateFilter = $request->has('start_date') || $request->has('end_date');
$query->inPeriod($request->start_date, $request->end_date, $dateField);
} else { // Para effective_date, usar COALESCE com planned_date como fallback
// Sem filtro de data: não mostrar transações futuras $dateColumn = $dateField === 'effective_date'
? DB::raw('COALESCE(effective_date, planned_date)')
: $dateField;
if ($hasDateFilter) {
if ($request->has('start_date') && $request->has('end_date')) {
// Ambas as datas especificadas
if ($dateField === 'effective_date') {
$query->whereRaw('COALESCE(effective_date, planned_date) >= ?', [$request->start_date])
->whereRaw('COALESCE(effective_date, planned_date) <= ?', [$request->end_date]);
} else {
$query->inPeriod($request->start_date, $request->end_date, $dateField);
}
} elseif ($request->has('start_date')) {
// Apenas data inicial - mostrar a partir desta data
if ($dateField === 'effective_date') {
$query->whereRaw('COALESCE(effective_date, planned_date) >= ?', [$request->start_date]);
} else {
$query->where($dateField, '>=', $request->start_date);
}
} elseif ($request->has('end_date')) {
// Apenas data final - mostrar até esta data (incluindo futuras)
if ($dateField === 'effective_date') {
$query->whereRaw('COALESCE(effective_date, planned_date) <= ?', [$request->end_date]);
} else {
$query->where($dateField, '<=', $request->end_date);
}
}
} elseif (!$hasActiveFilters) {
// Sem filtro de data E sem filtros ativos: não mostrar transações futuras
// Quando há filtros ativos, mostrar todas as transações (incluindo futuras)
$query->where('planned_date', '<=', now()->toDateString()); $query->where('planned_date', '<=', now()->toDateString());
} }

View File

@ -680,7 +680,10 @@
"quickCostCenter": { "quickCostCenter": {
"codePlaceholder": "E.g.: CC001", "codePlaceholder": "E.g.: CC001",
"codeHelp": "Optional code to identify the cost center" "codeHelp": "Optional code to identify the cost center"
} },
"filteredResults": "Filtered Results",
"filterActive": "Filters active",
"uncategorized": "Uncategorized"
}, },
"currencies": { "currencies": {
"BRL": "Brazilian Real", "BRL": "Brazilian Real",

View File

@ -688,7 +688,10 @@
"quickCostCenter": { "quickCostCenter": {
"codePlaceholder": "Ej: CC001", "codePlaceholder": "Ej: CC001",
"codeHelp": "Código opcional para identificar el centro de costo" "codeHelp": "Código opcional para identificar el centro de costo"
} },
"filteredResults": "Resultados Filtrados",
"filterActive": "Filtros activos",
"uncategorized": "Sin Categoría"
}, },
"currencies": { "currencies": {
"BRL": "Real Brasileño", "BRL": "Real Brasileño",

View File

@ -690,7 +690,10 @@
"quickCostCenter": { "quickCostCenter": {
"codePlaceholder": "Ex: CC001", "codePlaceholder": "Ex: CC001",
"codeHelp": "Código opcional para identificar o centro de custo" "codeHelp": "Código opcional para identificar o centro de custo"
} },
"filteredResults": "Resultados Filtrados",
"filterActive": "Filtros ativos",
"uncategorized": "Sem Categoria"
}, },
"currencies": { "currencies": {
"BRL": "Real Brasileiro", "BRL": "Real Brasileiro",

View File

@ -1060,6 +1060,9 @@ export default function Transactions() {
const pagination = currentCurrencyData?.pagination; const pagination = currentCurrencyData?.pagination;
const weeks = currentCurrencyData?.weeks || []; const weeks = currentCurrencyData?.weeks || [];
// Criar lista flat de todas as transações para modo filtrado
const allTransactions = weeks.flatMap(week => week.transactions || []);
// Calcular totais gerais // Calcular totais gerais
const totalStats = weeks.reduce((acc, week) => ({ const totalStats = weeks.reduce((acc, week) => ({
credits: acc.credits + (week.summary?.credits?.total || 0), credits: acc.credits + (week.summary?.credits?.total || 0),
@ -1301,8 +1304,258 @@ export default function Transactions() {
</div> </div>
)} )}
{/* Weeks List */} {/* Filtered Transactions List (flat view when filters are active) */}
{!loading && weeks.length > 0 && ( {!loading && hasActiveFilters && allTransactions.length > 0 && (
<div className="txn-weeks-container">
<div className="txn-week expanded">
{/* Header mostrando info do filtro */}
<div className="txn-week-header" style={{ cursor: 'default' }}>
<div className="txn-week-left">
<div className="txn-week-chevron">
<i className="bi bi-funnel text-warning"></i>
</div>
<div className="txn-week-info">
<h3>
{t('transactions.filteredResults') || 'Resultados Filtrados'}
<div className="txn-week-badges">
<span className="txn-week-badge count">{allTransactions.length}</span>
</div>
</h3>
<div className="dates text-warning">
<i className="bi bi-funnel me-1"></i>
{t('transactions.filterActive') || 'Filtros ativos'}
</div>
</div>
</div>
{/* Summary totals */}
<div className="txn-week-summary">
<div className="txn-week-stat">
<div className="txn-week-stat-label">{t('transactions.credits')}</div>
<div className="txn-week-stat-value credit">+{formatCurrency(totalStats.credits, selectedCurrency)}</div>
</div>
<div className="txn-week-stat">
<div className="txn-week-stat-label">{t('transactions.debits')}</div>
<div className="txn-week-stat-value debit">-{formatCurrency(totalStats.debits, selectedCurrency)}</div>
</div>
<div className="txn-week-stat">
<div className="txn-week-stat-label">{t('transactions.balance')}</div>
<div className={`txn-week-stat-value ${(totalStats.credits - totalStats.debits) >= 0 ? 'balance-pos' : 'balance-neg'}`}>
{(totalStats.credits - totalStats.debits) >= 0 ? '+' : ''}{formatCurrency(totalStats.credits - totalStats.debits, selectedCurrency)}
</div>
</div>
</div>
</div>
{/* All transactions in flat list */}
<div className="txn-week-body">
{isMobile ? (
// Mobile: Cards Layout
<div className="d-flex flex-column gap-2 p-2">
{allTransactions.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' : ''} ${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">
<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 */}
<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>
</div>
{/* Account + Category */}
<div className="d-flex flex-wrap gap-2 mb-2">
<span className="badge bg-secondary" 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>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => openQuickCategorizeModal(transaction)}
>
<i className="bi bi-tags"></i>
</button>
</div>
</div>
</div>
))}
</div>
) : (
// Desktop: Table Layout
<table className="txn-table">
<thead>
<tr>
<th style={{ width: '40px' }} className="text-center col-checkbox">
<input
type="checkbox"
className="form-check-input"
checked={allTransactions.length > 0 && allTransactions.every(t => selectedTransactionIds.has(t.id))}
onChange={() => {
const allIds = allTransactions.map(t => t.id);
const allSelected = allIds.every(id => selectedTransactionIds.has(id));
setSelectedTransactionIds(prev => {
const next = new Set(prev);
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id));
return next;
});
}}
/>
</th>
<th style={{ width: '90px' }} className="col-date">{t('transactions.date')}</th>
<th className="col-description">{t('transactions.description')}</th>
<th style={{ width: '120px' }} className="col-account">{t('transactions.account')}</th>
<th style={{ width: '140px' }} className="col-category">{t('transactions.category')}</th>
<th style={{ width: '110px' }} className="text-end col-amount">{t('transactions.amount')}</th>
<th style={{ width: '70px' }} className="text-center col-type">{t('transactions.type.label')}</th>
<th style={{ width: '80px' }} className="text-center col-status">{t('transactions.status.label')}</th>
<th style={{ width: '40px' }} className="text-center col-actions"></th>
</tr>
</thead>
<tbody>
{allTransactions.map(transaction => (
<tr
key={transaction.id}
ref={transaction.id === highlightedTransactionId ? highlightedRef : null}
className={`${transaction.is_overdue ? 'overdue' : ''} ${transaction.id === highlightedTransactionId ? 'highlighted-transaction' : ''} ${selectedTransactionIds.has(transaction.id) ? 'selected-row' : ''}`}
>
<td className="text-center col-checkbox">
<input
type="checkbox"
className="form-check-input"
checked={selectedTransactionIds.has(transaction.id)}
onChange={() => handleToggleTransaction(transaction.id)}
/>
</td>
<td className="col-date">
<span className="txn-date">
{formatDate(transaction.effective_date || transaction.planned_date)}
{transaction.is_overdue && <i className="bi bi-exclamation-triangle overdue-icon"></i>}
</span>
</td>
<td className="col-description">
<div className="d-flex align-items-center gap-1">
{transaction.is_transfer && <span className="txn-transfer-badge"><i className="bi bi-arrow-left-right"></i></span>}
{transaction.is_reconciled && (
<span className="txn-reconciled-badge" title={t('transactions.reconciled')}>
<i className="bi bi-link-45deg"></i>
</span>
)}
<span className="txn-description" onClick={() => openDetailModal(transaction)}>
{transaction.description}
</span>
</div>
</td>
<td className="col-account"><span className="txn-account">{transaction.account?.name}</span></td>
<td className="col-category">
{transaction.category && (
<span
className="txn-category-badge"
style={{ backgroundColor: transaction.category.color + '20', color: transaction.category.color }}
>
<i className={`bi ${transaction.category.icon}`}></i>
{transaction.category.name}
</span>
)}
</td>
<td className="col-amount">
<span className={`txn-amount ${transaction.type}`}>
{transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(transaction.amount || transaction.planned_amount, selectedCurrency)}
</span>
</td>
<td className="text-center col-type">
<span className={`txn-type-badge ${transaction.type}`}>
{transaction.type === 'credit' ? t('transactions.type.credit') : t('transactions.type.debit')}
</span>
</td>
<td className="text-center col-status">
<span className={`txn-status-badge ${transaction.status}`}>
{t(`transactions.status.${transaction.status}`)}
</span>
</td>
<td className="text-center col-actions">
<button
className="txn-actions-btn"
onClick={() => openQuickCategorizeModal(transaction)}
title={t('transactions.quickCategorize')}
>
<i className="bi bi-tags"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
{/* Weeks List (grouped view when NO filters are active) */}
{!loading && !hasActiveFilters && weeks.length > 0 && (
<div className="txn-weeks-container"> <div className="txn-weeks-container">
{weeks.map((week) => ( {weeks.map((week) => (
<div key={week.year_week} className={`txn-week ${expandedWeeks[week.year_week] ? 'expanded' : ''}`}> <div key={week.year_week} className={`txn-week ${expandedWeeks[week.year_week] ? 'expanded' : ''}`}>