feat: opções de visualização de transações (semanal, mensal, todas)

- Adicionado seletor de viewMode: 'week', 'month', 'all'
- Backend ajustado para trazer todos os dados quando viewMode é month/all
- Agrupamento por mês no frontend
- Lista flat para visualização 'todas'
- Traduções i18n para pt-BR, en, es
This commit is contained in:
marco 2025-12-19 15:00:36 +01:00
parent 44bc999840
commit 27d7e91896
5 changed files with 430 additions and 6 deletions

View File

@ -397,8 +397,13 @@ public function byWeek(Request $request): JsonResponse
// 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
// Verificar modo de visualização
$viewMode = $request->get('view_mode', 'week'); // 'week', 'month', 'all'
// Determinar quantidade de semanas por página
// Se há filtros OU viewMode é 'month' ou 'all', trazer todas as semanas
$shouldFetchAll = $hasActiveFilters || in_array($viewMode, ['month', 'all']);
$perPage = $shouldFetchAll ? 1000 : $request->get('per_page', 10);
$page = $request->get('page', 1);
$currency = $request->get('currency'); // Filtro de divisa opcional
$dateField = $request->get('date_field', 'planned_date');

View File

@ -569,6 +569,13 @@
"amount": "Amount",
"leaveEmptyForPlanned": "Leave empty to use planned amount",
"week": "Week",
"weekly": "Weekly",
"monthly": "Monthly",
"all": "All",
"viewWeekly": "Weekly view",
"viewMonthly": "Monthly view",
"viewAll": "View all",
"allTransactions": "All Transactions",
"type": {
"label": "Type",
"credit": "Credit",

View File

@ -577,6 +577,13 @@
"amount": "Valor",
"leaveEmptyForPlanned": "Dejar vacío para usar el valor previsto",
"week": "Semana",
"weekly": "Semanal",
"monthly": "Mensual",
"all": "Todas",
"viewWeekly": "Vista semanal",
"viewMonthly": "Vista mensual",
"viewAll": "Ver todas",
"allTransactions": "Todas las Transacciones",
"type": {
"label": "Tipo",
"credit": "Crédito",

View File

@ -579,6 +579,13 @@
"amount": "Valor",
"leaveEmptyForPlanned": "Deixe vazio para usar o valor previsto",
"week": "Semana",
"weekly": "Semanal",
"monthly": "Mensal",
"all": "Todas",
"viewWeekly": "Visualização semanal",
"viewMonthly": "Visualização mensal",
"viewAll": "Ver todas",
"allTransactions": "Todas as Transações",
"type": {
"label": "Tipo",
"credit": "Crédito",

View File

@ -40,6 +40,9 @@ export default function Transactions() {
// Estados de paginação
const [page, setPage] = useState(1);
const [perPage] = useState(5); // Semanas por página
// Estado de visualização: 'week', 'month', 'all'
const [viewMode, setViewMode] = useState('week');
// Estados de filtro
const [filters, setFilters] = useState({
@ -212,6 +215,7 @@ export default function Transactions() {
page,
per_page: perPage,
currency: selectedCurrency,
view_mode: viewMode, // Enviar modo de visualização para o backend
};
// Remover params vazios
@ -253,7 +257,7 @@ export default function Transactions() {
useEffect(() => {
loadWeeklyData();
}, [filters, page, selectedCurrency]); // eslint-disable-line react-hooks/exhaustive-deps
}, [filters, page, selectedCurrency, viewMode]); // eslint-disable-line react-hooks/exhaustive-deps
// Mobile resize detection
useEffect(() => {
@ -962,6 +966,12 @@ export default function Transactions() {
}
return `${startDay} ${startMonth} - ${endDay} ${endMonth}`;
};
// Formatar nome do mês
const formatMonthName = (month) => {
const date = new Date(month.year, month.month - 1, 1);
return date.toLocaleDateString(getLocale(), { month: 'long', year: 'numeric' });
};
// Funções de seleção de transações
const getAllVisibleTransactionIds = () => {
@ -1060,8 +1070,56 @@ export default function Transactions() {
const pagination = currentCurrencyData?.pagination;
const weeks = currentCurrencyData?.weeks || [];
// Criar lista flat de todas as transações para modo filtrado
// Criar lista flat de todas as transações para modo filtrado ou "todas"
const allTransactions = weeks.flatMap(week => week.transactions || []);
// Agrupar transações por mês para viewMode 'month'
const months = React.useMemo(() => {
if (viewMode !== 'month' || hasActiveFilters) return [];
const monthMap = {};
allTransactions.forEach(txn => {
const date = txn.effective_date || txn.planned_date;
const monthKey = date.substring(0, 7); // "YYYY-MM"
if (!monthMap[monthKey]) {
monthMap[monthKey] = {
month_key: monthKey,
year: parseInt(monthKey.split('-')[0]),
month: parseInt(monthKey.split('-')[1]),
transactions: [],
summary: {
credits: { total: 0, count: 0 },
debits: { total: 0, count: 0 },
pending: { total: 0, count: 0 },
total_transactions: 0,
balance: 0,
}
};
}
monthMap[monthKey].transactions.push(txn);
monthMap[monthKey].summary.total_transactions++;
const amount = txn.amount || txn.planned_amount;
if (txn.type === 'credit') {
monthMap[monthKey].summary.credits.total += amount;
monthMap[monthKey].summary.credits.count++;
} else {
monthMap[monthKey].summary.debits.total += amount;
monthMap[monthKey].summary.debits.count++;
}
if (txn.status === 'pending') {
monthMap[monthKey].summary.pending.total += amount;
monthMap[monthKey].summary.pending.count++;
}
});
// Calcular balance e ordenar
Object.values(monthMap).forEach(m => {
m.summary.balance = m.summary.credits.total - m.summary.debits.total;
});
return Object.values(monthMap).sort((a, b) => b.month_key.localeCompare(a.month_key));
}, [allTransactions, viewMode, hasActiveFilters]);
// Calcular totais gerais
const totalStats = weeks.reduce((acc, week) => ({
@ -1119,6 +1177,43 @@ export default function Transactions() {
</div>
</div>
{/* View Mode Selector + Stats */}
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
{/* View Mode Buttons */}
{!hasActiveFilters && (
<div className="btn-group" role="group">
<button
type="button"
className={`btn btn-sm ${viewMode === 'week' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('week')}
title={t('transactions.viewWeekly')}
>
<i className="bi bi-calendar-week me-1"></i>
<span className="d-none d-sm-inline">{t('transactions.weekly')}</span>
</button>
<button
type="button"
className={`btn btn-sm ${viewMode === 'month' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('month')}
title={t('transactions.viewMonthly')}
>
<i className="bi bi-calendar-month me-1"></i>
<span className="d-none d-sm-inline">{t('transactions.monthly')}</span>
</button>
<button
type="button"
className={`btn btn-sm ${viewMode === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('all')}
title={t('transactions.viewAll')}
>
<i className="bi bi-list-ul me-1"></i>
<span className="d-none d-sm-inline">{t('transactions.all')}</span>
</button>
</div>
)}
{hasActiveFilters && <div></div>}
</div>
{/* Stats Cards */}
<div className="txn-stats">
<div className="txn-stat-card">
@ -1554,8 +1649,8 @@ export default function Transactions() {
</div>
)}
{/* Weeks List (grouped view when NO filters are active) */}
{!loading && !hasActiveFilters && weeks.length > 0 && (
{/* Weeks List (grouped view when NO filters are active and viewMode is 'week') */}
{!loading && !hasActiveFilters && viewMode === 'week' && weeks.length > 0 && (
<div className="txn-weeks-container">
{weeks.map((week) => (
<div key={week.year_week} className={`txn-week ${expandedWeeks[week.year_week] ? 'expanded' : ''}`}>
@ -2201,6 +2296,309 @@ export default function Transactions() {
</div>
)}
{/* Monthly List (grouped view when NO filters and viewMode is 'month') */}
{!loading && !hasActiveFilters && viewMode === 'month' && months.length > 0 && (
<div className="txn-weeks-container">
{months.map((month) => (
<div key={month.month_key} className={`txn-week ${expandedWeeks[month.month_key] ? 'expanded' : ''}`}>
{/* Month Header */}
<div className="txn-week-header" onClick={() => toggleWeekExpansion(month.month_key)}>
<div className="txn-week-left">
<div className="txn-week-chevron">
<i className="bi bi-chevron-right"></i>
</div>
<div className="txn-week-info">
<h3>
{formatMonthName(month)}
<div className="txn-week-badges">
<span className="txn-week-badge count">{month.summary.total_transactions}</span>
</div>
</h3>
</div>
</div>
{/* Month Summary */}
<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(month.summary.credits.total, 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(month.summary.debits.total, selectedCurrency)}</div>
</div>
<div className="txn-week-stat">
<div className="txn-week-stat-label">{t('transactions.balance')}</div>
<div className={`txn-week-stat-value ${month.summary.balance >= 0 ? 'balance-pos' : 'balance-neg'}`}>
{month.summary.balance >= 0 ? '+' : ''}{formatCurrency(month.summary.balance, selectedCurrency)}
</div>
</div>
</div>
</div>
{/* Month Transactions */}
{expandedWeeks[month.month_key] && (
<div className="txn-week-body">
{isMobile ? (
// Mobile: Cards Layout
<div className="d-flex flex-column gap-2 p-2">
{month.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' : ''}`}
style={{ background: '#0f172a', cursor: 'pointer' }}
>
<div className="card-body p-3">
<div className="d-flex justify-content-between align-items-start mb-2">
<span className="text-slate-400" style={{ fontSize: '0.75rem' }}>
{formatDate(transaction.effective_date || transaction.planned_date)}
</span>
<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>
</div>
</div>
<div className="mb-2" onClick={() => openDetailModal(transaction)}>
<span className="text-white fw-medium" style={{ fontSize: '0.85rem' }}>
{transaction.description}
</span>
</div>
<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>
<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>
</div>
</div>
))}
</div>
) : (
// Desktop: Table Layout
<table className="txn-table">
<thead>
<tr>
<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>
{month.transactions.map(transaction => (
<tr
key={transaction.id}
ref={transaction.id === highlightedTransactionId ? highlightedRef : null}
className={`${transaction.is_overdue ? 'overdue' : ''} ${transaction.id === highlightedTransactionId ? 'highlighted-transaction' : ''}`}
>
<td className="col-date">
<span className="txn-date">
{formatDate(transaction.effective_date || transaction.planned_date)}
</span>
</td>
<td className="col-description">
<span className="txn-description" onClick={() => openDetailModal(transaction)}>
{transaction.description}
</span>
</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">
<div className="dropdown">
<button className="txn-actions-btn" type="button" data-bs-toggle="dropdown">
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end shadow-sm">
<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>
</ul>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
))}
</div>
)}
{/* All Transactions List (flat view when NO filters and viewMode is 'all') */}
{!loading && !hasActiveFilters && viewMode === 'all' && allTransactions.length > 0 && (
<div className="txn-weeks-container">
<div className="txn-week expanded">
<div className="txn-week-header" style={{ cursor: 'default' }}>
<div className="txn-week-left">
<div className="txn-week-chevron">
<i className="bi bi-list-ul text-info"></i>
</div>
<div className="txn-week-info">
<h3>
{t('transactions.allTransactions') || 'Todas as Transações'}
<div className="txn-week-badges">
<span className="txn-week-badge count">{allTransactions.length}</span>
</div>
</h3>
</div>
</div>
<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>
<div className="txn-week-body">
{isMobile ? (
<div className="d-flex flex-column gap-2 p-2">
{allTransactions.map(transaction => (
<div
key={transaction.id}
className={`card border-secondary ${transaction.is_overdue ? 'border-danger' : ''}`}
style={{ background: '#0f172a', cursor: 'pointer' }}
>
<div className="card-body p-3">
<div className="d-flex justify-content-between align-items-start mb-2">
<span className="text-slate-400" style={{ fontSize: '0.75rem' }}>
{formatDate(transaction.effective_date || transaction.planned_date)}
</span>
<span className={`badge ${transaction.type === 'credit' ? 'bg-success' : 'bg-danger'} bg-opacity-25`} style={{ fontSize: '0.65rem' }}>
{transaction.type === 'credit' ? t('transactions.type.credit') : t('transactions.type.debit')}
</span>
</div>
<div className="mb-2" onClick={() => openDetailModal(transaction)}>
<span className="text-white fw-medium">{transaction.description}</span>
</div>
<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'}`}>
{transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(transaction.amount || transaction.planned_amount, selectedCurrency)}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<table className="txn-table">
<thead>
<tr>
<th style={{ width: '90px' }}>{t('transactions.date')}</th>
<th>{t('transactions.description')}</th>
<th style={{ width: '120px' }}>{t('transactions.account')}</th>
<th style={{ width: '140px' }}>{t('transactions.category')}</th>
<th style={{ width: '110px' }} className="text-end">{t('transactions.amount')}</th>
<th style={{ width: '70px' }} className="text-center">{t('transactions.type.label')}</th>
<th style={{ width: '80px' }} className="text-center">{t('transactions.status.label')}</th>
<th style={{ width: '40px' }} className="text-center"></th>
</tr>
</thead>
<tbody>
{allTransactions.map(transaction => (
<tr key={transaction.id} className={transaction.is_overdue ? 'overdue' : ''}>
<td><span className="txn-date">{formatDate(transaction.effective_date || transaction.planned_date)}</span></td>
<td><span className="txn-description" onClick={() => openDetailModal(transaction)}>{transaction.description}</span></td>
<td><span className="txn-account">{transaction.account?.name}</span></td>
<td>
{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><span className={`txn-amount ${transaction.type}`}>{transaction.type === 'credit' ? '+' : '-'}{formatCurrency(transaction.amount || transaction.planned_amount, selectedCurrency)}</span></td>
<td className="text-center"><span className={`txn-type-badge ${transaction.type}`}>{transaction.type === 'credit' ? t('transactions.type.credit') : t('transactions.type.debit')}</span></td>
<td className="text-center"><span className={`txn-status-badge ${transaction.status}`}>{t(`transactions.status.${transaction.status}`)}</span></td>
<td className="text-center">
<button className="txn-actions-btn" onClick={() => openQuickCategorizeModal(transaction)}><i className="bi bi-tags"></i></button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
{/* Modal de Criar/Editar */}
<Modal
show={showModal}