webmoney/frontend/src/pages/TransactionsByWeek.jsx
marco f2e032f002 feat: adicionar filtro 'Sem Categoria' nas transações
- Backend: suporte para category_id=uncategorized nos endpoints index e byWeek
- Frontend: opção 'Sem Categoria' no CategorySelector com prop showUncategorized
- Permite filtrar 525 transações importadas que ainda não foram categorizadas
2025-12-19 12:12:07 +01:00

3245 lines
141 KiB
JavaScript
Executable File

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
import { transactionService, accountService, categoryService, costCenterService, transferDetectionService } from '../services/api';
import { useFormatters } from '../hooks';
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';
import ConfirmModal from '../components/ConfirmModal';
export default function Transactions() {
const { t, i18n } = useTranslation();
const { currency: formatCurrency, date: formatDate } = useFormatters();
// Helper para locale dinâmico
const getLocale = () => i18n.language === 'pt-BR' ? 'pt-BR' : i18n.language === 'es' ? 'es-ES' : 'en-US';
const [searchParams, setSearchParams] = useSearchParams();
const highlightedRef = useRef(null);
// 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([]);
const [categories, setCategories] = useState([]);
const [costCenters, setCostCenters] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Estado de divisa selecionada
const [selectedCurrency, setSelectedCurrency] = useState(null);
const [availableCurrencies, setAvailableCurrencies] = useState([]);
// Estados de paginação
const [page, setPage] = useState(1);
const [perPage] = useState(5); // Semanas por página
// Estados de filtro
const [filters, setFilters] = useState({
account_id: '',
category_id: '',
cost_center_id: '',
type: '',
status: '',
search: '',
start_date: '',
end_date: '',
date_field: 'effective_date', // Ordenar por data efetiva (com fallback para planejada)
});
// Estados de modal
const [showModal, setShowModal] = useState(false);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showCompleteModal, setShowCompleteModal] = useState(false);
const [showTransferModal, setShowTransferModal] = useState(false);
const [showSplitModal, setShowSplitModal] = useState(false);
const [showRecurrenceModal, setShowRecurrenceModal] = useState(false);
const [recurrenceTransaction, setRecurrenceTransaction] = useState(null);
const [editingTransaction, setEditingTransaction] = useState(null);
const [selectedTransaction, setSelectedTransaction] = useState(null);
const [formData, setFormData] = useState({
account_id: '',
category_id: '',
cost_center_id: '',
type: 'debit',
planned_amount: '',
amount: '',
description: '',
notes: '',
planned_date: new Date().toISOString().split('T')[0],
effective_date: '',
status: 'pending',
reference: '',
});
// Estados de toast
const [toast, setToast] = useState({ show: false, message: '', type: 'success' });
// Estados de complete
const [completeData, setCompleteData] = useState({
amount: '',
effective_date: new Date().toISOString().split('T')[0],
});
// Estados de transferência
const [transferData, setTransferData] = useState({
from_account_id: '',
to_account_id: '',
amount: '',
description: '',
date: new Date().toISOString().split('T')[0],
notes: '',
});
// Estados de divisão
const [splitData, setSplitData] = useState({
splits: [
{ category_id: '', amount: '', description: '' },
{ category_id: '', amount: '', description: '' },
],
});
// Estados de semanas expandidas
const [expandedWeeks, setExpandedWeeks] = useState({});
// Estado para transferências expandidas
const [expandedTransfers, setExpandedTransfers] = useState({});
// Estado para filtros colapsados
const [filtersExpanded, setFiltersExpanded] = useState(false);
// Estados de categorização em lote
const [showBatchModal, setShowBatchModal] = useState(false);
const [batchPreview, setBatchPreview] = useState(null);
const [selectedTransactionIds, setSelectedTransactionIds] = useState(new Set());
const [loadingBatch, setLoadingBatch] = useState(false);
const [executingBatch, setExecutingBatch] = useState(false);
const [batchFormData, setBatchFormData] = useState({
category_id: '',
cost_center_id: '',
add_keyword: true,
});
// Estados de converter para transferência
const [showConvertTransferModal, setShowConvertTransferModal] = useState(false);
const [convertTransferData, setConvertTransferData] = useState({
source: null,
pairs: [],
loading: false,
selectedPairId: null,
});
// Estados de conciliar com passivo
const [showReconcileLiabilityModal, setShowReconcileLiabilityModal] = useState(false);
const [reconcileLiabilityData, setReconcileLiabilityData] = useState({
transaction: null,
installments: [],
loading: false,
selectedInstallmentId: null,
});
// Estados de criação rápida (inline no modal de transação)
const [showQuickCategoryModal, setShowQuickCategoryModal] = useState(false);
const [showQuickCostCenterModal, setShowQuickCostCenterModal] = useState(false);
const [quickCategoryData, setQuickCategoryData] = useState({
name: '',
parent_id: '',
type: 'both',
icon: 'bi-tag',
});
const [quickCostCenterData, setQuickCostCenterData] = useState({
name: '',
code: '',
});
const [savingQuickItem, setSavingQuickItem] = useState(false);
const [quickCreateSource, setQuickCreateSource] = useState('form'); // 'form' ou 'batch'
// Estado para modal de confirmação
const [confirmModal, setConfirmModal] = useState({
show: false,
message: '',
onConfirm: null,
variant: 'danger',
});
// Estado para modal de categorização rápida individual
const [showQuickCategorizeModal, setShowQuickCategorizeModal] = useState(false);
const [quickCategorizeData, setQuickCategorizeData] = useState({
transaction: null,
category_id: '',
cost_center_id: '',
add_keyword: true,
});
const [savingQuickCategorize, setSavingQuickCategorize] = useState(false);
// Calcular se há filtros ativos (excluindo date_field que é sempre preenchido)
const hasActiveFilters = Object.entries(filters).some(([key, v]) => key !== 'date_field' && v !== '');
const activeFiltersCount = Object.entries(filters).filter(([key, v]) => key !== 'date_field' && v !== '').length;
// Carregar dados iniciais
const loadData = useCallback(async () => {
try {
setLoading(true);
const [accountsRes, categoriesRes, costCentersRes] = await Promise.all([
accountService.getAll(),
categoryService.getAll({ flat: true }),
costCenterService.getAll(),
]);
setAccounts(Array.isArray(accountsRes) ? accountsRes : (accountsRes.data || []));
setCategories(Array.isArray(categoriesRes) ? categoriesRes : (categoriesRes.data || []));
setCostCenters(Array.isArray(costCentersRes) ? costCentersRes : (costCentersRes.data || []));
} catch (err) {
setError(err.response?.data?.message || 'Error loading data');
} finally {
setLoading(false);
}
}, []);
// Carregar transações por semana
const loadWeeklyData = useCallback(async () => {
try {
setLoading(true);
const params = {
...filters,
page,
per_page: perPage,
currency: selectedCurrency,
};
// Remover params vazios
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null) {
delete params[key];
}
});
const response = await transactionService.getByWeek(params);
setWeeklyData(response);
// Atualizar divisas disponíveis
if (response.currencies && response.currencies.length > 0) {
setAvailableCurrencies(response.currencies);
// Se não tem divisa selecionada, usar a primeira
if (!selectedCurrency && response.currencies.length > 0) {
setSelectedCurrency(response.currencies[0]);
}
}
// Expandir a primeira semana por padrão
if (response.data && selectedCurrency && response.data[selectedCurrency]?.weeks?.length > 0) {
const firstWeek = response.data[selectedCurrency].weeks[0]?.year_week;
if (firstWeek && !expandedWeeks[firstWeek]) {
setExpandedWeeks(prev => ({ ...prev, [firstWeek]: true }));
}
}
} catch (err) {
setError(err.response?.data?.message || 'Error loading transactions');
} finally {
setLoading(false);
}
}, [filters, page, perPage, selectedCurrency, expandedWeeks]);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
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');
if (highlightId) {
const id = parseInt(highlightId, 10);
setHighlightedTransactionId(id);
// Buscar transação para encontrar a semana correta
transactionService.getById(id).then(transaction => {
if (transaction && transaction.effective_date) {
// Calcular year_week da transação
const date = new Date(transaction.effective_date);
const year = date.getFullYear();
const startOfYear = new Date(year, 0, 1);
const days = Math.floor((date - startOfYear) / (24 * 60 * 60 * 1000));
const week = Math.ceil((days + startOfYear.getDay() + 1) / 7);
const yearWeek = `${year}-W${String(week).padStart(2, '0')}`;
// Expandir a semana da transação
setExpandedWeeks(prev => ({ ...prev, [yearWeek]: true }));
// Scroll para a transação após um pequeno delay
setTimeout(() => {
if (highlightedRef.current) {
highlightedRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 500);
}
}).catch(err => {
console.error('Error fetching transaction for highlight:', err);
});
// Limpar o parâmetro da URL após 5 segundos
setTimeout(() => {
setHighlightedTransactionId(null);
searchParams.delete('highlight');
setSearchParams(searchParams, { replace: true });
}, 5000);
}
}, [searchParams, setSearchParams]);
// Handlers de filtro
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters(prev => ({ ...prev, [name]: value }));
setPage(1); // Reset para primeira página ao filtrar
};
const clearFilters = () => {
setFilters({
account_id: '',
category_id: '',
cost_center_id: '',
type: '',
status: '',
search: '',
start_date: '',
end_date: '',
});
setPage(1);
};
// Toggle expansão de semana
const toggleWeekExpansion = (yearWeek) => {
setExpandedWeeks(prev => ({
...prev,
[yearWeek]: !prev[yearWeek]
}));
};
// Handlers de formulário
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const resetForm = () => {
setFormData({
account_id: accounts[0]?.id || '',
category_id: '',
cost_center_id: '',
type: 'debit',
planned_amount: '',
amount: '',
description: '',
notes: '',
planned_date: new Date().toISOString().split('T')[0],
effective_date: '',
status: 'pending',
reference: '',
});
setEditingTransaction(null);
};
const openNewModal = () => {
resetForm();
setShowModal(true);
};
const openEditModal = (transaction) => {
setEditingTransaction(transaction);
setFormData({
account_id: transaction.account?.id || '',
category_id: transaction.category?.id || '',
cost_center_id: transaction.cost_center?.id || '',
type: transaction.type,
planned_amount: transaction.planned_amount,
amount: transaction.amount || '',
description: transaction.description,
notes: transaction.notes || '',
planned_date: transaction.planned_date,
effective_date: transaction.effective_date || '',
status: transaction.status,
reference: transaction.reference || '',
});
setShowModal(true);
};
const openDetailModal = async (transaction) => {
try {
const detail = await transactionService.getById(transaction.id);
setSelectedTransaction(detail);
setShowDetailModal(true);
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
const openCompleteModal = (transaction) => {
setSelectedTransaction(transaction);
setCompleteData({
amount: transaction.planned_amount,
effective_date: new Date().toISOString().split('T')[0],
});
setShowCompleteModal(true);
};
// Salvar transação
const handleSubmit = async (e) => {
e.preventDefault();
try {
const dataToSend = {
...formData,
planned_amount: parseFloat(formData.planned_amount),
amount: formData.amount ? parseFloat(formData.amount) : null,
category_id: formData.category_id || null,
cost_center_id: formData.cost_center_id || null,
effective_date: formData.effective_date || null,
reference: formData.reference || null,
notes: formData.notes || null,
};
if (editingTransaction) {
await transactionService.update(editingTransaction.id, dataToSend);
showToast(t('transactions.updated'), 'success');
} else {
await transactionService.create(dataToSend);
showToast(t('transactions.created'), 'success');
}
setShowModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// Marcar como concluída
const handleComplete = async () => {
try {
await transactionService.complete(selectedTransaction.id, {
amount: parseFloat(completeData.amount),
effective_date: completeData.effective_date,
});
showToast(t('transactions.completed'), 'success');
setShowCompleteModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// Cancelar transação
const handleCancel = async (transaction) => {
setConfirmModal({
show: true,
message: t('transactions.confirmCancel'),
variant: 'warning',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.cancel(transaction.id);
showToast(t('transactions.cancelled'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// Reverter para pendente
const handleRevert = async (transaction) => {
try {
await transactionService.revert(transaction.id);
showToast(t('transactions.reverted'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// Duplicar transação
const handleDuplicate = async (transaction) => {
try {
await transactionService.duplicate(transaction.id);
showToast(t('transactions.duplicated'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// Excluir transação
const handleDelete = async (transaction) => {
setConfirmModal({
show: true,
message: t('transactions.confirmDelete'),
variant: 'danger',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.delete(transaction.id);
showToast(t('transactions.deleted'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// ========================================
// CRIAÇÃO RÁPIDA DE CATEGORIA/CENTRO DE CUSTO
// ========================================
const openQuickCategoryModal = () => {
setQuickCategoryData({
name: '',
parent_id: '',
type: formData.type === 'debit' ? 'expense' : formData.type === 'credit' ? 'income' : 'both',
icon: 'bi-tag',
});
setQuickCreateSource('form');
setShowQuickCategoryModal(true);
};
// Abrir modal de criação rápida de categoria (do batch modal)
const openQuickCategoryModalForBatch = () => {
setQuickCategoryData({
name: '',
parent_id: '',
type: 'both',
icon: 'bi-tag',
});
setQuickCreateSource('batch');
setShowQuickCategoryModal(true);
};
const handleQuickCategorySubmit = async (e) => {
e.preventDefault();
try {
setSavingQuickItem(true);
const result = await categoryService.create(quickCategoryData);
const newCategory = result.data || result;
// Recarregar categorias
const categoriesRes = await categoryService.getAll({ flat: true });
setCategories(Array.isArray(categoriesRes) ? categoriesRes : (categoriesRes.data || []));
// Selecionar a nova categoria (form ou batch)
if (quickCreateSource === 'batch') {
setBatchFormData(prev => ({ ...prev, category_id: newCategory.id }));
} else {
setFormData(prev => ({ ...prev, category_id: newCategory.id }));
}
showToast(t('categories.createSuccess'), 'success');
setShowQuickCategoryModal(false);
} catch (err) {
showToast(err.response?.data?.message || t('categories.createError'), 'danger');
} finally {
setSavingQuickItem(false);
}
};
const openQuickCostCenterModal = () => {
setQuickCostCenterData({
name: '',
code: '',
});
setQuickCreateSource('form');
setShowQuickCostCenterModal(true);
};
// Abrir modal de criação rápida de centro de custo (do batch modal)
const openQuickCostCenterModalForBatch = () => {
setQuickCostCenterData({
name: '',
code: '',
});
setQuickCreateSource('batch');
setShowQuickCostCenterModal(true);
};
const handleQuickCostCenterSubmit = async (e) => {
e.preventDefault();
try {
setSavingQuickItem(true);
const result = await costCenterService.create(quickCostCenterData);
const newCostCenter = result.data || result;
// Recarregar centros de custo
const costCentersRes = await costCenterService.getAll();
setCostCenters(Array.isArray(costCentersRes) ? costCentersRes : (costCentersRes.data || []));
// Selecionar o novo centro de custo (form ou batch)
if (quickCreateSource === 'batch') {
setBatchFormData(prev => ({ ...prev, cost_center_id: newCostCenter.id }));
} else {
setFormData(prev => ({ ...prev, cost_center_id: newCostCenter.id }));
}
showToast(t('costCenters.createSuccess'), 'success');
setShowQuickCostCenterModal(false);
} catch (err) {
showToast(err.response?.data?.message || t('costCenters.createError'), 'danger');
} finally {
setSavingQuickItem(false);
}
};
// ========================================
// CATEGORIZAÇÃO RÁPIDA INDIVIDUAL
// ========================================
const openQuickCategorizeModal = (transaction) => {
setQuickCategorizeData({
transaction: transaction,
category_id: transaction.category_id || '',
cost_center_id: transaction.cost_center_id || '',
add_keyword: true,
});
setShowQuickCategorizeModal(true);
};
const handleQuickCategorizeSubmit = async (e) => {
e.preventDefault();
try {
setSavingQuickCategorize(true);
const updateData = {
category_id: quickCategorizeData.category_id || null,
cost_center_id: quickCategorizeData.cost_center_id || null,
};
// Se add_keyword está ativo e há descrição original, criar keyword
if (quickCategorizeData.add_keyword && quickCategorizeData.transaction.original_description) {
// Atualizar transação e criar keyword via batch com uma única transação
await categoryService.batchCategorize({
transaction_ids: [quickCategorizeData.transaction.id],
category_id: updateData.category_id,
cost_center_id: updateData.cost_center_id,
add_keyword: true,
});
} else {
// Apenas atualizar a transação
await transactionService.update(quickCategorizeData.transaction.id, updateData);
}
showToast(t('transactions.categorized'), 'success');
setShowQuickCategorizeModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
} finally {
setSavingQuickCategorize(false);
}
};
// ========================================
// EFETIVAÇÃO RÁPIDA
// ========================================
const handleQuickComplete = async (transaction) => {
try {
await transactionService.quickComplete(transaction.id, {
amount: transaction.planned_amount,
effective_date: new Date().toISOString().split('T')[0],
});
showToast(t('transactions.quickCompleted'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// ========================================
// TRANSFERÊNCIA ENTRE CONTAS
// ========================================
const openTransferModal = () => {
setTransferData({
from_account_id: accounts[0]?.id || '',
to_account_id: accounts[1]?.id || '',
amount: '',
description: '',
date: new Date().toISOString().split('T')[0],
notes: '',
});
setShowTransferModal(true);
};
const handleTransferChange = (e) => {
const { name, value } = e.target;
setTransferData(prev => ({ ...prev, [name]: value }));
};
const handleTransferSubmit = async () => {
try {
await transactionService.transfer({
...transferData,
amount: parseFloat(transferData.amount),
});
showToast(t('transactions.transferCreated'), 'success');
setShowTransferModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// ========================================
// DIVISÃO DE TRANSAÇÕES
// ========================================
const openSplitModal = (transaction) => {
setSelectedTransaction(transaction);
const originalAmount = parseFloat(transaction.amount || transaction.planned_amount);
setSplitData({
splits: [
{ category_id: '', amount: (originalAmount / 2).toFixed(2), description: '' },
{ category_id: '', amount: (originalAmount / 2).toFixed(2), description: '' },
],
});
setShowSplitModal(true);
};
// RECORRÊNCIA
// ========================================
const openRecurrenceModal = (transaction) => {
setRecurrenceTransaction(transaction);
setShowRecurrenceModal(true);
};
const handleRecurrenceSuccess = (result) => {
showToast(t('recurring.createSuccess'), 'success');
loadData();
};
const handleSplitChange = (index, field, value) => {
setSplitData(prev => {
const newSplits = [...prev.splits];
newSplits[index] = { ...newSplits[index], [field]: value };
return { splits: newSplits };
});
};
const addSplitRow = () => {
setSplitData(prev => ({
splits: [...prev.splits, { category_id: '', amount: '', description: '' }],
}));
};
const removeSplitRow = (index) => {
if (splitData.splits.length <= 2) return;
setSplitData(prev => ({
splits: prev.splits.filter((_, i) => i !== index),
}));
};
const handleSplitSubmit = async () => {
try {
const splitsToSend = splitData.splits.map(s => ({
category_id: s.category_id || null,
amount: parseFloat(s.amount),
description: s.description || null,
}));
await transactionService.split(selectedTransaction.id, splitsToSend);
showToast(t('transactions.splitCreated'), 'success');
setShowSplitModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
const handleUnsplit = async (transaction) => {
setConfirmModal({
show: true,
message: t('transactions.unsplitConfirm'),
variant: 'warning',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.unsplit(transaction.id);
showToast(t('transactions.unsplitSuccess'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// ========================================
// DESVINCULAR TRANSFERÊNCIA
// ========================================
const toggleTransferExpanded = (transferId) => {
setExpandedTransfers(prev => ({
...prev,
[transferId]: !prev[transferId]
}));
};
const handleUnlinkTransfer = async (transfer) => {
setConfirmModal({
show: true,
message: t('transactions.unlinkTransferConfirm'),
variant: 'warning',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.unlinkTransfer(transfer.debit_transaction_id || transfer.id);
showToast(t('transactions.unlinkTransferSuccess'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// Converter transação em transferência
const openConvertTransferModal = async (transaction) => {
setShowConvertTransferModal(true);
setConvertTransferData({
source: transaction,
pairs: [],
loading: true,
selectedPairId: null,
});
try {
const result = await transferDetectionService.findPairs(transaction.id);
setConvertTransferData(prev => ({
...prev,
source: result.source,
pairs: result.pairs,
loading: false,
}));
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
setShowConvertTransferModal(false);
}
};
const handleConvertToTransfer = async () => {
const { source, selectedPairId } = convertTransferData;
if (!source || !selectedPairId) return;
try {
// Determinar qual é o débito e qual é o crédito
const debitId = source.type === 'debit' ? source.id : selectedPairId;
const creditId = source.type === 'credit' ? source.id : selectedPairId;
await transferDetectionService.confirm(debitId, creditId);
showToast(t('transactions.convertToTransferSuccess'), 'success');
setShowConvertTransferModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// Conciliar com passivo
const openReconcileLiabilityModal = async (transaction) => {
// Só transações de débito podem ser conciliadas
if (transaction.type !== 'debit') {
showToast(t('transactions.onlyDebitCanReconcile'), 'warning');
return;
}
// Já está conciliada?
if (transaction.is_reconciled) {
showToast(t('transactions.alreadyReconciled'), 'warning');
return;
}
setShowReconcileLiabilityModal(true);
setReconcileLiabilityData({
transaction,
installments: [],
loading: true,
selectedInstallmentId: null,
});
try {
const result = await transactionService.findLiabilityInstallments(transaction.id);
setReconcileLiabilityData(prev => ({
...prev,
transaction: result.transaction,
installments: result.installments,
loading: false,
}));
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
setShowReconcileLiabilityModal(false);
}
};
const handleReconcileWithLiability = async () => {
const { transaction, selectedInstallmentId } = reconcileLiabilityData;
if (!transaction || !selectedInstallmentId) return;
try {
await transactionService.reconcileWithLiability(transaction.id, selectedInstallmentId);
showToast(t('transactions.reconcileSuccess'), 'success');
setShowReconcileLiabilityModal(false);
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
};
// Toast helper
const showToast = (message, type) => {
setToast({ show: true, message, type });
};
// Obter moeda da conta
const getAccountCurrency = (accountId) => {
if (!accounts || !Array.isArray(accounts)) return 'EUR';
const account = accounts.find(a => a.id === parseInt(accountId));
return account?.currency || 'EUR';
};
// Obter categorias pai (sem parent_id)
const parentCategories = Array.isArray(categories) ? categories.filter(c => !c.parent_id) : [];
// Obter subcategorias de uma categoria pai
const getSubcategories = (parentId) => {
if (!categories || !Array.isArray(categories)) return [];
return categories.filter(c => c.parent_id === parentId);
};
// Status badge
const getStatusBadge = (status) => {
const colors = {
pending: 'warning',
completed: 'success',
cancelled: 'secondary',
effective: 'success',
scheduled: 'primary',
};
const labels = {
pending: t('transactions.status.pending'),
completed: t('transactions.status.completed'),
cancelled: t('transactions.status.cancelled'),
effective: t('transactions.status.effective'),
scheduled: t('transactions.status.scheduled'),
};
return <span className={`badge bg-${colors[status]}`}>{labels[status]}</span>;
};
// Type badge
const getTypeBadge = (type) => {
const colors = { credit: 'success', debit: 'danger' };
const labels = { credit: t('transactions.type.credit'), debit: t('transactions.type.debit') };
return <span className={`badge bg-${colors[type]}`}>{labels[type]}</span>;
};
// Formatar nome da semana
const formatWeekName = (week) => {
const startDate = new Date(week.start_date);
const endDate = new Date(week.end_date);
const startDay = startDate.getDate();
const endDay = endDate.getDate();
const startMonth = startDate.toLocaleDateString(getLocale(), { month: 'short' });
const endMonth = endDate.toLocaleDateString(getLocale(), { month: 'short' });
if (startMonth === endMonth) {
return `${startDay} - ${endDay} ${startMonth}`;
}
return `${startDay} ${startMonth} - ${endDay} ${endMonth}`;
};
// Funções de seleção de transações
const getAllVisibleTransactionIds = () => {
const ids = [];
weeks.forEach(week => {
week.transactions?.forEach(t => ids.push(t.id));
});
return ids;
};
const handleToggleTransaction = (id) => {
setSelectedTransactionIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectAllVisible = () => {
const allIds = getAllVisibleTransactionIds();
if (selectedTransactionIds.size === allIds.length) {
setSelectedTransactionIds(new Set());
} else {
setSelectedTransactionIds(new Set(allIds));
}
};
// Funções de categorização em lote
const handleOpenBatchModal = () => {
// Permitir abrir modal se há transações selecionadas OU se há filtros ativos
if (selectedTransactionIds.size === 0 && !hasActiveFilters) {
showToast(t('transactions.noTransactionsSelected'), 'warning');
return;
}
setShowBatchModal(true);
setBatchFormData({
category_id: '',
cost_center_id: '',
add_keyword: !!filters.search,
});
};
const handleCloseBatchModal = () => {
setShowBatchModal(false);
setBatchFormData({
category_id: '',
cost_center_id: '',
add_keyword: true,
});
};
const handleExecuteBatchManual = async () => {
if (!batchFormData.category_id && !batchFormData.cost_center_id) {
showToast(t('transactions.batchSelectRequired'), 'warning');
return;
}
// Obter IDs selecionados
const selectedIds = Array.from(selectedTransactionIds);
// Verificar se há transações selecionadas ou filtros ativos
if (selectedIds.length === 0 && !hasActiveFilters) {
showToast(t('transactions.noTransactionsSelected'), 'warning');
return;
}
setExecutingBatch(true);
try {
const response = await categoryService.categorizeBatchManual(
batchFormData.category_id || null,
batchFormData.cost_center_id || null,
filters, // Enviar filtros sempre
batchFormData.add_keyword,
selectedIds.length > 0 ? selectedIds : null // Só enviar IDs se houver seleção
);
if (response.success) {
let message = `${response.data.updated} ${t('transactions.batchUpdated')}`;
if (response.data.keyword_added) {
message += `${t('transactions.keywordAdded')}: "${response.data.keyword_text}"`;
}
showToast(message, 'success');
handleCloseBatchModal();
setSelectedTransactionIds(new Set());
loadWeeklyData();
}
} catch (error) {
showToast(error.response?.data?.message || t('categories.batchError'), 'error');
} finally {
setExecutingBatch(false);
}
};
// Obter dados da divisa atual
const currentCurrencyData = weeklyData?.data?.[selectedCurrency];
const pagination = currentCurrencyData?.pagination;
const weeks = currentCurrencyData?.weeks || [];
// Calcular totais gerais
const totalStats = weeks.reduce((acc, week) => ({
credits: acc.credits + (week.summary?.credits?.total || 0),
debits: acc.debits + (week.summary?.debits?.total || 0),
pending: acc.pending + (week.summary?.pending?.total || 0),
transactions: acc.transactions + (week.summary?.total_transactions || 0),
}), { credits: 0, debits: 0, pending: 0, transactions: 0 });
if (error) {
return (
<div className="alert alert-danger">
<i className="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
);
}
return (
<div className="txn-page">
{/* Header compacto */}
<div className="txn-header">
<div className="txn-header-title">
<i className="bi bi-arrow-left-right" style={{ fontSize: '1.5rem', color: '#3b82f6' }}></i>
<div>
<h1>{t('transactions.title')}</h1>
<span className="subtitle">{totalStats.transactions} {t('transactions.items')} {selectedCurrency || 'EUR'}</span>
</div>
</div>
<div className="txn-header-actions">
{hasActiveFilters && (
<button
className={`btn btn-sm ${selectedTransactionIds.size > 0 ? 'btn-warning' : 'btn-outline-warning'}`}
onClick={handleOpenBatchModal}
disabled={selectedTransactionIds.size === 0}
>
<i className="bi bi-lightning-charge"></i>
<span className="d-none d-md-inline ms-1">
{t('categories.batchCategorize')} {selectedTransactionIds.size > 0 && `(${selectedTransactionIds.size})`}
</span>
</button>
)}
<Link to="/import" className="btn btn-outline-primary btn-sm">
<i className="bi bi-upload"></i>
<span className="d-none d-md-inline ms-1">{t('nav.import')}</span>
</Link>
<button className="btn btn-outline-info btn-sm" onClick={openTransferModal}>
<i className="bi bi-arrow-left-right"></i>
<span className="d-none d-md-inline ms-1">{t('transactions.transfer')}</span>
</button>
<button className="btn btn-primary btn-sm" onClick={openNewModal}>
<i className="bi bi-plus-lg"></i>
<span className="d-none d-md-inline ms-1">{t('transactions.new')}</span>
</button>
</div>
</div>
{/* Stats Cards */}
<div className="txn-stats">
<div className="txn-stat-card">
<div className="txn-stat-icon credit">
<i className="bi bi-arrow-down-circle"></i>
</div>
<div className="txn-stat-content">
<div className="txn-stat-label">{t('transactions.credits')}</div>
<div className="txn-stat-value positive">+{formatCurrency(totalStats.credits, selectedCurrency)}</div>
</div>
</div>
<div className="txn-stat-card">
<div className="txn-stat-icon debit">
<i className="bi bi-arrow-up-circle"></i>
</div>
<div className="txn-stat-content">
<div className="txn-stat-label">{t('transactions.debits')}</div>
<div className="txn-stat-value negative">-{formatCurrency(totalStats.debits, selectedCurrency)}</div>
</div>
</div>
<div className="txn-stat-card">
<div className="txn-stat-icon balance">
<i className="bi bi-wallet2"></i>
</div>
<div className="txn-stat-content">
<div className="txn-stat-label">{t('transactions.balance')}</div>
<div className={`txn-stat-value ${totalStats.credits - totalStats.debits >= 0 ? 'positive' : 'negative'}`}>
{totalStats.credits - totalStats.debits >= 0 ? '+' : ''}{formatCurrency(totalStats.credits - totalStats.debits, selectedCurrency)}
</div>
</div>
</div>
<div className="txn-stat-card">
<div className="txn-stat-icon pending">
<i className="bi bi-clock-history"></i>
</div>
<div className="txn-stat-content">
<div className="txn-stat-label">{t('transactions.pending')}</div>
<div className="txn-stat-value">{formatCurrency(totalStats.pending, selectedCurrency)}</div>
</div>
</div>
</div>
{/* Currency Tabs */}
{availableCurrencies.length > 1 && (
<div className="txn-currency-tabs">
{availableCurrencies.map(currency => (
<button
key={currency}
className={`txn-currency-tab ${selectedCurrency === currency ? 'active' : ''}`}
onClick={() => { setSelectedCurrency(currency); setPage(1); }}
>
{currency}
{weeklyData?.data?.[currency]?.total_transactions && (
<span className="count">{weeklyData.data[currency].total_transactions}</span>
)}
</button>
))}
</div>
)}
{/* Filtros colapsáveis */}
<div className="txn-filters">
<div className="txn-filters-toggle" onClick={() => setFiltersExpanded(!filtersExpanded)}>
<div className="txn-filters-toggle-left">
<i className={`bi ${filtersExpanded ? 'bi-chevron-down' : 'bi-chevron-right'}`}></i>
<span><i className="bi bi-funnel me-2"></i>{t('common.filters')}</span>
</div>
<div className="txn-filters-active">
{activeFiltersCount > 0 && (
<span className="txn-filter-tag">{activeFiltersCount} {t('common.active')}</span>
)}
{filters.search && <span className="txn-filter-tag">"{filters.search}"</span>}
</div>
</div>
{filtersExpanded && (
<div className="txn-filters-body">
{/* 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"
className="form-control"
name="search"
value={filters.search}
onChange={handleFilterChange}
placeholder={t('transactions.searchPlaceholder')}
/>
</div>
<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')}
showUncategorized={true}
/>
</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>
{/* Loading */}
{loading && (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
)}
{/* Empty State */}
{!loading && weeks.length === 0 && (
<div className="txn-empty">
<i className="bi bi-inbox"></i>
<p>{t('transactions.empty')}</p>
</div>
)}
{/* Weeks List */}
{!loading && weeks.length > 0 && (
<div className="txn-weeks-container">
{weeks.map((week) => (
<div key={week.year_week} className={`txn-week ${expandedWeeks[week.year_week] ? 'expanded' : ''}`}>
{/* Week Header */}
<div className="txn-week-header" onClick={() => toggleWeekExpansion(week.year_week)}>
<div className="txn-week-left">
<div className="txn-week-chevron">
<i className="bi bi-chevron-right"></i>
</div>
<div className="txn-week-info">
<h3>
{t('transactions.week')} {week.week_number}
<div className="txn-week-badges">
<span className="txn-week-badge count">{week.summary.total_transactions}</span>
{week.summary.overdue_count > 0 && (
<span className="txn-week-badge overdue">{week.summary.overdue_count} {t('transactions.overdue')}</span>
)}
</div>
</h3>
<div className="dates">{formatWeekName(week)}</div>
</div>
</div>
{/* Week 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(week.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(week.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 ${week.summary.balance >= 0 ? 'balance-pos' : 'balance-neg'}`}>
{week.summary.balance >= 0 ? '+' : ''}{formatCurrency(week.summary.balance, selectedCurrency)}
</div>
</div>
{week.summary.pending.count > 0 && (
<div className="txn-week-stat">
<div className="txn-week-stat-label">{t('transactions.pending')}</div>
<div className="txn-week-stat-value pending">{formatCurrency(week.summary.pending.total, selectedCurrency)}</div>
</div>
)}
</div>
</div>
{/* 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" 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>
{hasActiveFilters && (
<th style={{ width: '40px' }} className="text-center col-checkbox">
<input
type="checkbox"
className="form-check-input"
checked={week.transactions?.length > 0 && week.transactions.every(t => selectedTransactionIds.has(t.id))}
onChange={() => {
const weekIds = week.transactions?.map(t => t.id) || [];
const allSelected = weekIds.every(id => selectedTransactionIds.has(id));
setSelectedTransactionIds(prev => {
const next = new Set(prev);
weekIds.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>
{week.transactions.map(transaction => (
<tr
key={transaction.id}
ref={transaction.id === highlightedTransactionId ? highlightedRef : null}
className={`${transaction.is_overdue ? 'overdue' : ''} ${transaction.id === highlightedTransactionId ? 'highlighted-transaction' : ''} ${hasActiveFilters && selectedTransactionIds.has(transaction.id) ? 'selected-row' : ''}`}
>
{hasActiveFilters && (
<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>
{transaction.original_description && transaction.original_description !== transaction.description && (
<div className="txn-original-desc">
<i className="bi bi-bank"></i>
{transaction.original_description.substring(0, 45)}{transaction.original_description.length > 45 ? '...' : ''}
</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">
<div className="dropdown">
<button className="txn-actions-btn" 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>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Transferências Agrupadas */}
{week.transfers && week.transfers.length > 0 && (
<div className="txn-transfers-section">
<h6 className="txn-transfers-title">
<i className="bi bi-arrow-left-right"></i>
{t('transactions.transfers')} ({week.transfers.length})
</h6>
<div className="txn-transfers-list">
{week.transfers.map(transfer => (
<div key={transfer.id} className={`txn-transfer-item ${expandedTransfers[transfer.id] ? 'expanded' : ''}`}>
<div className="txn-transfer-main" onClick={() => toggleTransferExpanded(transfer.id)}>
<div className="txn-transfer-info">
<span className="txn-transfer-expand">
<i className={`bi ${expandedTransfers[transfer.id] ? 'bi-chevron-down' : 'bi-chevron-right'}`}></i>
</span>
<span className="txn-transfer-icon">
<i className="bi bi-arrow-left-right"></i>
</span>
<div className="txn-transfer-details">
<div className="txn-transfer-accounts">
<span className="from">{transfer.from_account?.name || '?'}</span>
<i className="bi bi-arrow-right"></i>
<span className="to">{transfer.to_account?.name || '?'}</span>
</div>
<div className="txn-transfer-meta">
{formatDate(transfer.effective_date || transfer.planned_date)}
{transfer.description && <span className="separator"></span>}
{transfer.description && <span>{transfer.description}</span>}
</div>
</div>
</div>
<div className="txn-transfer-amount">
{formatCurrency(transfer.amount, selectedCurrency)}
</div>
</div>
{/* Detalhes expandidos */}
{expandedTransfers[transfer.id] && (
<div className="txn-transfer-expanded">
<div className="txn-transfer-transactions">
{/* Transação de Débito */}
<div className="txn-transfer-txn debit">
<div className="txn-transfer-txn-header">
<span className="txn-transfer-txn-type debit">
<i className="bi bi-arrow-up"></i> {t('transactions.type.debit')}
</span>
<span className="txn-transfer-txn-id">ID: {transfer.debit_transaction_id || '-'}</span>
</div>
<div className="txn-transfer-txn-body">
<div className="txn-transfer-txn-account">
<i className="bi bi-wallet2"></i>
{transfer.from_account?.name || '?'}
</div>
<div className="txn-transfer-txn-amount debit">
-{formatCurrency(transfer.amount, selectedCurrency)}
</div>
</div>
</div>
{/* Transação de Crédito */}
<div className="txn-transfer-txn credit">
<div className="txn-transfer-txn-header">
<span className="txn-transfer-txn-type credit">
<i className="bi bi-arrow-down"></i> {t('transactions.type.credit')}
</span>
<span className="txn-transfer-txn-id">ID: {transfer.credit_transaction_id || '-'}</span>
</div>
<div className="txn-transfer-txn-body">
<div className="txn-transfer-txn-account">
<i className="bi bi-wallet2"></i>
{transfer.to_account?.name || '?'}
</div>
<div className="txn-transfer-txn-amount credit">
+{formatCurrency(transfer.amount, selectedCurrency)}
</div>
</div>
</div>
</div>
{/* Ações */}
<div className="txn-transfer-actions">
<button
className="btn btn-outline-warning btn-sm"
onClick={(e) => { e.stopPropagation(); handleUnlinkTransfer(transfer); }}
>
<i className="bi bi-link-45deg me-1"></i>
{t('transactions.unlinkTransfer')}
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
))}
{/* Pagination */}
{pagination && pagination.total_pages > 1 && (
<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>
{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>
</div>
)}
</div>
)}
{/* Modal de Criar/Editar */}
<Modal
show={showModal}
onClose={() => setShowModal(false)}
title={editingTransaction ? t('transactions.edit') : t('transactions.new')}
size="lg"
>
<form onSubmit={handleSubmit}>
<div className="row g-3">
{/* Conta */}
<div className="col-md-6">
<label className="form-label">{t('transactions.account')} *</label>
<select
className="form-select"
name="account_id"
value={formData.account_id}
onChange={handleInputChange}
required
>
<option value="">{t('common.select')}</option>
{accounts.map(account => (
<option key={account.id} value={account.id}>
{account.name} ({account.currency})
</option>
))}
</select>
</div>
{/* Tipo */}
<div className="col-md-6">
<label className="form-label">{t('transactions.type.label')} *</label>
<div className="btn-group w-100">
<button
type="button"
className={`btn ${formData.type === 'debit' ? 'btn-danger' : 'btn-outline-danger'}`}
onClick={() => setFormData(prev => ({ ...prev, type: 'debit' }))}
>
<i className="bi bi-arrow-up me-1"></i>
{t('transactions.type.debit')}
</button>
<button
type="button"
className={`btn ${formData.type === 'credit' ? 'btn-success' : 'btn-outline-success'}`}
onClick={() => setFormData(prev => ({ ...prev, type: 'credit' }))}
>
<i className="bi bi-arrow-down me-1"></i>
{t('transactions.type.credit')}
</button>
</div>
</div>
{/* Descrição */}
<div className="col-12">
<label className="form-label">{t('transactions.description')} *</label>
<input
type="text"
className="form-control"
name="description"
value={formData.description}
onChange={handleInputChange}
required
maxLength={255}
/>
</div>
{/* Valor Planejado */}
<div className="col-md-6">
<label className="form-label">{t('transactions.plannedAmount')} *</label>
<div className="input-group">
<span className="input-group-text">
{formData.account_id ? getAccountCurrency(formData.account_id) : '€'}
</span>
<input
type="number"
className="form-control"
name="planned_amount"
value={formData.planned_amount}
onChange={handleInputChange}
required
min="0"
step="0.01"
/>
</div>
</div>
{/* Data Planejada */}
<div className="col-md-6">
<label className="form-label">{t('transactions.plannedDate')} *</label>
<input
type="date"
className="form-control"
name="planned_date"
value={formData.planned_date}
onChange={handleInputChange}
required
/>
</div>
{/* Categoria */}
<div className="col-md-6">
<label className="form-label">{t('transactions.category')}</label>
<CategorySelector
categories={categories}
value={formData.category_id}
onChange={handleInputChange}
onAddNew={openQuickCategoryModal}
placeholder={t('common.none')}
/>
</div>
{/* Centro de Custo */}
<div className="col-md-6">
<label className="form-label">{t('transactions.costCenter')}</label>
<div className="input-group">
<select
className="form-select"
name="cost_center_id"
value={formData.cost_center_id}
onChange={handleInputChange}
>
<option value="">{t('common.none')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCostCenterModal}
title={t('costCenters.newCostCenter')}
>
<i className="bi bi-plus-lg"></i>
</button>
</div>
</div>
{/* Referência */}
<div className="col-md-6">
<label className="form-label">{t('transactions.reference')}</label>
<input
type="text"
className="form-control"
name="reference"
value={formData.reference}
onChange={handleInputChange}
maxLength={100}
placeholder={t('transactions.referencePlaceholder')}
/>
</div>
{/* Status */}
<div className="col-md-6">
<label className="form-label">{t('transactions.status.label')}</label>
<select
className="form-select"
name="status"
value={formData.status}
onChange={handleInputChange}
>
<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>
{/* Campos de Efetivação (se não pendente) */}
{formData.status === 'completed' && (
<>
<div className="col-md-6">
<label className="form-label">{t('transactions.effectiveAmount')}</label>
<div className="input-group">
<span className="input-group-text">
{formData.account_id ? getAccountCurrency(formData.account_id) : '€'}
</span>
<input
type="number"
className="form-control"
name="amount"
value={formData.amount}
onChange={handleInputChange}
min="0"
step="0.01"
placeholder={formData.planned_amount}
/>
</div>
<small className="text-muted">{t('transactions.leaveEmptyForPlanned')}</small>
</div>
<div className="col-md-6">
<label className="form-label">{t('transactions.effectiveDate')}</label>
<input
type="date"
className="form-control"
name="effective_date"
value={formData.effective_date}
onChange={handleInputChange}
/>
</div>
</>
)}
{/* Notas */}
<div className="col-12">
<label className="form-label">{t('transactions.notes')}</label>
<textarea
className="form-control"
name="notes"
value={formData.notes}
onChange={handleInputChange}
rows={2}
/>
</div>
</div>
<div className="d-flex justify-content-end gap-2 mt-4">
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-primary">
<i className="bi bi-check-lg me-1"></i>
{editingTransaction ? t('common.save') : t('common.create')}
</button>
</div>
</form>
</Modal>
{/* Modal de Detalhes */}
<Modal
show={showDetailModal}
onClose={() => setShowDetailModal(false)}
title={t('transactions.details')}
>
{selectedTransaction && (
<div>
<div className="row g-3">
<div className="col-6">
<strong>{t('transactions.description')}</strong>
<p>{selectedTransaction.description}</p>
</div>
<div className="col-6">
<strong>{t('transactions.account')}</strong>
<p>{selectedTransaction.account?.name}</p>
</div>
{selectedTransaction.original_description && selectedTransaction.original_description !== selectedTransaction.description && (
<div className="col-12">
<strong>{t('transactions.originalDescription')}</strong>
<p className="text-muted bg-light p-2 rounded" style={{ fontFamily: 'monospace', fontSize: '0.9em' }}>
<i className="bi bi-bank me-2"></i>
{selectedTransaction.original_description}
</p>
</div>
)}
<div className="col-6">
<strong>{t('transactions.type.label')}</strong>
<p>{getTypeBadge(selectedTransaction.type)}</p>
</div>
<div className="col-6">
<strong>{t('transactions.status.label')}</strong>
<p>{getStatusBadge(selectedTransaction.status)}</p>
</div>
<div className="col-6">
<strong>{t('transactions.plannedAmount')}</strong>
<p>{formatCurrency(selectedTransaction.planned_amount, selectedTransaction.account?.currency || 'EUR')}</p>
</div>
<div className="col-6">
<strong>{t('transactions.plannedDate')}</strong>
<p>{formatDate(selectedTransaction.planned_date)}</p>
</div>
{selectedTransaction.status === 'completed' && (
<>
<div className="col-6">
<strong>{t('transactions.effectiveAmount')}</strong>
<p>{formatCurrency(selectedTransaction.amount, selectedTransaction.account?.currency || 'EUR')}</p>
</div>
<div className="col-6">
<strong>{t('transactions.effectiveDate')}</strong>
<p>{formatDate(selectedTransaction.effective_date)}</p>
</div>
</>
)}
{selectedTransaction.category && (
<div className="col-6">
<strong>{t('transactions.category')}</strong>
<p>
<span
className="badge"
style={{
backgroundColor: selectedTransaction.category.color + '20',
color: selectedTransaction.category.color,
}}
>
<i className={`bi ${selectedTransaction.category.icon} me-1`}></i>
{selectedTransaction.category.parent?.name
? `${selectedTransaction.category.parent.name} > ${selectedTransaction.category.name}`
: selectedTransaction.category.name
}
</span>
</p>
</div>
)}
{selectedTransaction.cost_center && (
<div className="col-6">
<strong>{t('transactions.costCenter')}</strong>
<p>
<span
className="badge"
style={{
backgroundColor: selectedTransaction.cost_center.color + '20',
color: selectedTransaction.cost_center.color,
}}
>
{selectedTransaction.cost_center.name}
</span>
</p>
</div>
)}
{selectedTransaction.reference && (
<div className="col-12">
<strong>{t('transactions.reference')}</strong>
<p>{selectedTransaction.reference}</p>
</div>
)}
{selectedTransaction.notes && (
<div className="col-12">
<strong>{t('transactions.notes')}</strong>
<p className="text-muted">{selectedTransaction.notes}</p>
</div>
)}
</div>
<div className="d-flex justify-content-end mt-4">
<button className="btn btn-secondary" onClick={() => setShowDetailModal(false)}>
{t('common.close')}
</button>
</div>
</div>
)}
</Modal>
{/* Modal de Completar */}
<Modal
show={showCompleteModal}
onClose={() => setShowCompleteModal(false)}
title={t('transactions.markComplete')}
>
{selectedTransaction && (
<div>
<p className="text-muted mb-3">
{t('transactions.completeDescription', { description: selectedTransaction.description })}
</p>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">{t('transactions.effectiveAmount')}</label>
<div className="input-group">
<span className="input-group-text">
{selectedTransaction.account?.currency || 'EUR'}
</span>
<input
type="number"
className="form-control"
value={completeData.amount}
onChange={(e) => setCompleteData(prev => ({ ...prev, amount: e.target.value }))}
min="0"
step="0.01"
/>
</div>
</div>
<div className="col-md-6">
<label className="form-label">{t('transactions.effectiveDate')}</label>
<input
type="date"
className="form-control"
value={completeData.effective_date}
onChange={(e) => setCompleteData(prev => ({ ...prev, effective_date: e.target.value }))}
/>
</div>
</div>
<div className="d-flex justify-content-end gap-2 mt-4">
<button className="btn btn-secondary" onClick={() => setShowCompleteModal(false)}>
{t('common.cancel')}
</button>
<button className="btn btn-success" onClick={handleComplete}>
<i className="bi bi-check-lg me-1"></i>
{t('transactions.complete')}
</button>
</div>
</div>
)}
</Modal>
{/* Toast */}
<Toast
show={toast.show}
message={toast.message}
type={toast.type}
onClose={() => setToast(prev => ({ ...prev, show: false }))}
/>
{/* Modal de Confirmação */}
<ConfirmModal
show={confirmModal.show}
message={confirmModal.message}
variant={confirmModal.variant}
onConfirm={confirmModal.onConfirm}
onCancel={() => setConfirmModal(prev => ({ ...prev, show: false }))}
/>
{/* Modal de Transferência */}
<Modal
show={showTransferModal}
onHide={() => setShowTransferModal(false)}
title={t('transactions.transfer')}
size="lg"
>
<div className="modal-body">
<div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i>
{t('transactions.transferInfo')}
</div>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">{t('transactions.sourceAccount')} *</label>
<select
className="form-select"
value={transferData.from_account_id}
onChange={(e) => setTransferData(prev => ({ ...prev, from_account_id: e.target.value }))}
required
>
<option value="">{t('common.select')}</option>
{accounts.map(acc => (
<option key={acc.id} value={acc.id}>{acc.name} ({acc.currency})</option>
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label">{t('transactions.destinationAccount')} *</label>
<select
className="form-select"
value={transferData.to_account_id}
onChange={(e) => setTransferData(prev => ({ ...prev, to_account_id: e.target.value }))}
required
>
<option value="">{t('common.select')}</option>
{accounts.filter(acc => acc.id !== parseInt(transferData.from_account_id)).map(acc => (
<option key={acc.id} value={acc.id}>{acc.name} ({acc.currency})</option>
))}
</select>
</div>
<div className="col-md-6">
<label className="form-label">{t('transactions.amount')} *</label>
<input
type="number"
className="form-control"
value={transferData.amount}
onChange={(e) => setTransferData(prev => ({ ...prev, amount: e.target.value }))}
min="0.01"
step="0.01"
required
/>
</div>
<div className="col-md-6">
<label className="form-label">{t('transactions.date')} *</label>
<input
type="date"
className="form-control"
value={transferData.date}
onChange={(e) => setTransferData(prev => ({ ...prev, date: e.target.value }))}
required
/>
</div>
<div className="col-12">
<label className="form-label">{t('transactions.description')}</label>
<input
type="text"
className="form-control"
value={transferData.description}
onChange={(e) => setTransferData(prev => ({ ...prev, description: e.target.value }))}
placeholder={t('transactions.transferDescription')}
/>
</div>
</div>
<div className="d-flex justify-content-end gap-2 mt-4">
<button className="btn btn-secondary" onClick={() => setShowTransferModal(false)}>
{t('common.cancel')}
</button>
<button
className="btn btn-success"
onClick={handleTransferSubmit}
disabled={!transferData.from_account_id || !transferData.to_account_id || !transferData.amount || !transferData.date}
>
<i className="bi bi-arrow-left-right me-1"></i>
{t('transactions.transfer')}
</button>
</div>
</div>
</Modal>
{/* Modal de Divisão */}
<Modal
show={showSplitModal}
onHide={() => setShowSplitModal(false)}
title={t('transactions.splitTransaction')}
size="lg"
>
{selectedTransaction && (
<div className="modal-body">
<div className="alert alert-warning">
<i className="bi bi-exclamation-triangle me-2"></i>
{t('transactions.splitWarning')}
</div>
<div className="mb-3">
<strong>{t('transactions.originalAmount')}:</strong>{' '}
<span className={selectedTransaction.type === 'debit' ? 'text-danger' : 'text-success'}>
{selectedTransaction.type === 'debit' ? '-' : '+'}
{formatCurrency(selectedTransaction.amount, selectedTransaction.account?.currency || 'EUR')}
</span>
</div>
<div className="mb-3">
<strong>{t('transactions.description')}:</strong> {selectedTransaction.description}
</div>
<hr />
<div className="mb-3 d-flex justify-content-between align-items-center">
<h6 className="mb-0">{t('transactions.splits')}</h6>
<button
className="btn btn-sm btn-outline-primary"
onClick={() => setSplitData(prev => ({ splits: [...prev.splits, { category_id: '', amount: '' }] }))}
>
<i className="bi bi-plus-lg me-1"></i>
{t('transactions.addSplit')}
</button>
</div>
{splitData.splits.map((split, index) => (
<div key={index} className="row g-2 mb-2 align-items-end">
<div className="col-md-6">
<label className="form-label">{t('transactions.category')}</label>
<select
className="form-select"
value={split.category_id}
onChange={(e) => {
const newSplits = [...splitData.splits];
newSplits[index].category_id = e.target.value;
setSplitData({ splits: newSplits });
}}
>
<option value="">{t('common.select')}</option>
{categories.filter(c =>
c.type === 'both' ||
(selectedTransaction.type === 'debit' && c.type === 'expense') ||
(selectedTransaction.type === 'credit' && c.type === 'income')
).map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label">{t('transactions.amount')}</label>
<input
type="number"
className="form-control"
value={split.amount}
onChange={(e) => {
const newSplits = [...splitData.splits];
newSplits[index].amount = e.target.value;
setSplitData({ splits: newSplits });
}}
min="0.01"
step="0.01"
/>
</div>
<div className="col-md-2">
<button
className="btn btn-outline-danger w-100"
onClick={() => {
const newSplits = splitData.splits.filter((_, i) => i !== index);
setSplitData({ splits: newSplits });
}}
disabled={splitData.splits.length <= 2}
>
<i className="bi bi-trash"></i>
</button>
</div>
</div>
))}
<div className="mt-3 p-3 bg-light rounded">
<div className="d-flex justify-content-between">
<span>{t('transactions.totalSplits')}:</span>
<strong className={
splitData.splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0) === parseFloat(selectedTransaction.amount)
? 'text-success' : 'text-danger'
}>
{formatCurrency(splitData.splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0), selectedTransaction.account?.currency || 'EUR')}
</strong>
</div>
<div className="d-flex justify-content-between">
<span>{t('transactions.remaining')}:</span>
<strong>
{formatCurrency(
parseFloat(selectedTransaction.amount) - splitData.splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0),
selectedTransaction.account?.currency || 'EUR'
)}
</strong>
</div>
</div>
<div className="d-flex justify-content-end gap-2 mt-4">
<button className="btn btn-secondary" onClick={() => setShowSplitModal(false)}>
{t('common.cancel')}
</button>
<button
className="btn btn-primary"
onClick={handleSplitSubmit}
disabled={
splitData.splits.length < 2 ||
splitData.splits.some(s => !s.amount || parseFloat(s.amount) <= 0) ||
Math.abs(splitData.splits.reduce((sum, s) => sum + (parseFloat(s.amount) || 0), 0) - parseFloat(selectedTransaction.amount)) > 0.01
}
>
<i className="bi bi-diagram-3 me-1"></i>
{t('transactions.split')}
</button>
</div>
</div>
)}
</Modal>
{/* Modal de Categorização em Lote Manual */}
{showBatchModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
<div className="modal-header border-secondary">
<h5 className="modal-title text-white">
<i className="bi bi-tags-fill me-2 text-warning"></i>
{t('transactions.batchCategorizeManual')}
</h5>
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
</div>
<div className="modal-body">
{/* Info das transações selecionadas */}
<div className="alert mb-4 py-3" style={{ background: '#0f172a', border: '1px solid #334155' }}>
<div className="d-flex align-items-center">
<div className="me-3">
<i className={`bi ${selectedTransactionIds.size > 0 ? 'bi-check2-square' : 'bi-funnel'} text-warning`} style={{ fontSize: '2rem' }}></i>
</div>
<div>
{selectedTransactionIds.size > 0 ? (
<>
<h5 className="mb-1 text-white">
{selectedTransactionIds.size} {t('transactions.transactionsSelected')}
</h5>
<small className="text-slate-400">
{t('transactions.batchWillApplySelected')}
</small>
</>
) : (
<>
<h5 className="mb-1 text-white">
{t('transactions.batchAllFiltered')}
</h5>
<small className="text-slate-400">
{t('transactions.batchWillApplyFiltered')}
</small>
</>
)}
</div>
</div>
</div>
{/* Formulário de Categorização */}
<div className="mb-4">
<label className="form-label text-slate-300">
<i className="bi bi-folder me-1"></i>
{t('categories.category')}
</label>
<div className="input-group">
<CategorySelector
categories={categories}
value={batchFormData.category_id}
onChange={(e) => setBatchFormData({ ...batchFormData, category_id: e.target.value })}
placeholder={t('transactions.selectCategory')}
/>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCategoryModalForBatch}
title={t('categories.newCategory')}
>
<i className="bi bi-plus-lg"></i>
</button>
</div>
</div>
<div className="mb-4">
<label className="form-label text-slate-300">
<i className="bi bi-building me-1"></i>
{t('costCenters.costCenter')}
</label>
<div className="input-group">
<select
className="form-select"
value={batchFormData.cost_center_id}
onChange={(e) => setBatchFormData({ ...batchFormData, cost_center_id: e.target.value })}
style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }}
>
<option value="">{t('transactions.selectCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
<button
type="button"
className="btn btn-outline-primary"
onClick={openQuickCostCenterModalForBatch}
title={t('costCenters.newCostCenter')}
>
<i className="bi bi-plus-lg"></i>
</button>
</div>
</div>
{/* Opção de adicionar keyword */}
{filters.search && (
<div className="form-check mb-3 p-3 rounded" style={{ background: '#0f172a', border: '1px solid #334155' }}>
<input
type="checkbox"
className="form-check-input"
id="addKeywordCheck"
checked={batchFormData.add_keyword}
onChange={(e) => setBatchFormData({ ...batchFormData, add_keyword: e.target.checked })}
/>
<label className="form-check-label text-slate-300" htmlFor="addKeywordCheck">
<i className="bi bi-key me-1 text-warning"></i>
{t('transactions.addAsKeyword')}
<span className="badge bg-warning ms-2">
"{filters.search}"
</span>
</label>
<div className="text-slate-500 small mt-1">
{t('transactions.addAsKeywordHelp')}
</div>
</div>
)}
{/* Aviso se nada selecionado */}
{!batchFormData.category_id && !batchFormData.cost_center_id && (
<div className="alert alert-warning py-2">
<i className="bi bi-exclamation-triangle me-2"></i>
<small>{t('transactions.batchSelectRequired')}</small>
</div>
)}
</div>
<div className="modal-footer border-secondary">
<button type="button" className="btn btn-outline-secondary" onClick={handleCloseBatchModal}>
{t('common.cancel')}
</button>
<button
type="button"
className="btn btn-warning"
onClick={handleExecuteBatchManual}
disabled={executingBatch || (!batchFormData.category_id && !batchFormData.cost_center_id)}
>
{executingBatch ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
{t('common.processing')}
</>
) : (
<>
<i className="bi bi-check2-all me-2"></i>
{t('common.applyToSelected')} ({selectedTransactionIds.size})
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal de Converter para Transferência */}
<Modal
show={showConvertTransferModal}
onHide={() => setShowConvertTransferModal(false)}
title={
<>
<i className="bi bi-arrow-left-right me-2 text-purple"></i>
{t('transactions.convertToTransfer')}
</>
}
size="lg"
>
{convertTransferData.loading ? (
<div className="text-center py-5">
<div className="spinner-border text-info" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-slate-400 mt-3">{t('common.loading')}</p>
</div>
) : (
<div className="convert-transfer-modal">
{/* Transação Origem */}
{convertTransferData.source && (
<div className="card mb-4" style={{ background: 'rgba(15, 23, 42, 0.8)', border: '1px solid rgba(99, 102, 241, 0.3)' }}>
<div className="card-header" style={{ background: 'rgba(99, 102, 241, 0.2)', borderBottom: '1px solid rgba(99, 102, 241, 0.3)' }}>
<h6 className="mb-0 text-white">
<i className="bi bi-bookmark-fill me-2 text-indigo-400"></i>
{t('transactions.sourceTransaction')}
</h6>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-6">
<p className="mb-1">
<strong className="text-slate-300">{t('transactions.description')}:</strong>{' '}
<span className="text-white">{convertTransferData.source.description}</span>
</p>
<p className="mb-1">
<strong className="text-slate-300">{t('accounts.account')}:</strong>{' '}
<span className="text-cyan-400">{convertTransferData.source.account_name}</span>
</p>
</div>
<div className="col-md-6">
<p className="mb-1">
<strong className="text-slate-300">{t('transactions.amount')}:</strong>{' '}
<span className={convertTransferData.source.type === 'credit' ? 'text-success' : 'text-danger'}>
{convertTransferData.source.type === 'credit' ? '+' : '-'}
{formatCurrency(convertTransferData.source.amount, 'EUR')}
</span>
</p>
<p className="mb-1">
<strong className="text-slate-300">{t('transactions.date')}:</strong>{' '}
<span className="text-white">{formatDate(convertTransferData.source.date)}</span>
</p>
</div>
</div>
</div>
</div>
)}
{/* Lista de possíveis pares */}
<h6 className="text-white mb-3">
<i className="bi bi-search me-2"></i>
{t('transactions.selectPairTransaction')}
</h6>
{convertTransferData.pairs.length === 0 ? (
<div className="alert alert-warning">
<i className="bi bi-exclamation-triangle me-2"></i>
{t('transactions.noPairsFound')}
</div>
) : (
<div className="transfer-pairs-list" style={{ maxHeight: '350px', overflowY: 'auto' }}>
{convertTransferData.pairs.map((pair) => (
<div
key={pair.id}
className={`card mb-2 transfer-pair-item ${convertTransferData.selectedPairId === pair.id ? 'selected' : ''}`}
style={{
background: convertTransferData.selectedPairId === pair.id
? 'rgba(99, 102, 241, 0.25)'
: 'rgba(30, 41, 59, 0.6)',
border: convertTransferData.selectedPairId === pair.id
? '2px solid rgba(99, 102, 241, 0.7)'
: '1px solid rgba(71, 85, 105, 0.5)',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onClick={() => setConvertTransferData(prev => ({ ...prev, selectedPairId: pair.id }))}
>
<div className="card-body py-3">
<div className="d-flex justify-content-between align-items-start">
<div className="flex-grow-1">
<div className="d-flex align-items-center gap-2 mb-2">
<span className={`badge ${pair.confidence.level === 'high' ? 'bg-success' : pair.confidence.level === 'medium' ? 'bg-warning text-dark' : 'bg-secondary'}`}>
{pair.confidence.percentage}%
</span>
<span className="text-white fw-medium">{pair.description}</span>
</div>
<div className="small text-slate-400">
<span className="me-3">
<i className="bi bi-wallet2 me-1"></i>
{pair.account_name}
</span>
<span className="me-3">
<i className="bi bi-calendar me-1"></i>
{formatDate(pair.date)}
{pair.days_diff > 0 && (
<span className="text-slate-500 ms-1">
({pair.days_diff} {pair.days_diff === 1 ? t('common.day') : t('common.days')})
</span>
)}
</span>
</div>
</div>
<div className="text-end">
<div className={pair.type === 'credit' ? 'text-success fs-5' : 'text-danger fs-5'}>
{pair.type === 'credit' ? '+' : '-'}
{formatCurrency(pair.amount, 'EUR')}
</div>
{convertTransferData.selectedPairId === pair.id && (
<i className="bi bi-check-circle-fill text-success mt-1"></i>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Botões de ação */}
<div className="d-flex justify-content-end gap-2 mt-4">
<button className="btn btn-secondary" onClick={() => setShowConvertTransferModal(false)}>
{t('common.cancel')}
</button>
<button
className="btn btn-primary"
onClick={handleConvertToTransfer}
disabled={!convertTransferData.selectedPairId}
>
<i className="bi bi-link-45deg me-1"></i>
{t('transactions.linkAsTransfer')}
</button>
</div>
</div>
)}
</Modal>
{/* Modal de Conciliar com Passivo */}
<Modal
show={showReconcileLiabilityModal}
onHide={() => setShowReconcileLiabilityModal(false)}
title={
<>
<i className="bi bi-link-45deg me-2 text-purple"></i>
{t('transactions.reconcileWithLiability')}
</>
}
size="lg"
>
{reconcileLiabilityData.loading ? (
<div className="text-center py-5">
<div className="spinner-border text-info" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="text-slate-400 mt-3">{t('common.loading')}</p>
</div>
) : (
<div className="reconcile-liability-modal">
{/* Transação */}
{reconcileLiabilityData.transaction && (
<div className="card mb-4" style={{ background: 'rgba(15, 23, 42, 0.8)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<div className="card-header" style={{ background: 'rgba(239, 68, 68, 0.15)', borderBottom: '1px solid rgba(239, 68, 68, 0.3)' }}>
<h6 className="mb-0 text-white">
<i className="bi bi-receipt me-2 text-red-400"></i>
{t('transactions.transaction')}
</h6>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-8">
<p className="mb-1 text-white">{reconcileLiabilityData.transaction.description}</p>
</div>
<div className="col-md-4 text-end">
<span className="text-danger fs-5">
-{formatCurrency(reconcileLiabilityData.transaction.amount, selectedCurrency)}
</span>
<br />
<small className="text-slate-400">{formatDate(reconcileLiabilityData.transaction.date)}</small>
</div>
</div>
</div>
</div>
)}
{/* Lista de parcelas compatíveis */}
<h6 className="text-white mb-3">
<i className="bi bi-search me-2"></i>
{t('transactions.selectLiabilityInstallment')}
</h6>
{reconcileLiabilityData.installments.length === 0 ? (
<div className="alert alert-warning">
<i className="bi bi-exclamation-triangle me-2"></i>
{t('transactions.noInstallmentsFound')}
</div>
) : (
<div className="liability-installments-list" style={{ maxHeight: '350px', overflowY: 'auto' }}>
{reconcileLiabilityData.installments.map((installment) => (
<div
key={installment.id}
className={`card mb-2 installment-item ${reconcileLiabilityData.selectedInstallmentId === installment.id ? 'selected' : ''}`}
style={{
background: reconcileLiabilityData.selectedInstallmentId === installment.id
? 'rgba(168, 85, 247, 0.25)'
: 'rgba(30, 41, 59, 0.6)',
border: reconcileLiabilityData.selectedInstallmentId === installment.id
? '2px solid rgba(168, 85, 247, 0.7)'
: '1px solid rgba(71, 85, 105, 0.5)',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onClick={() => setReconcileLiabilityData(prev => ({ ...prev, selectedInstallmentId: installment.id }))}
>
<div className="card-body py-3">
<div className="d-flex justify-content-between align-items-start">
<div className="flex-grow-1">
<div className="d-flex align-items-center gap-2 mb-2">
<span className={`badge ${installment.confidence.level === 'high' ? 'bg-success' : installment.confidence.level === 'medium' ? 'bg-warning text-dark' : 'bg-secondary'}`}>
{installment.confidence.percentage}%
</span>
<span className="text-white fw-medium">{installment.liability_name}</span>
{installment.creditor && (
<span className="text-slate-400 small">({installment.creditor})</span>
)}
</div>
<div className="small text-slate-400">
<span className="me-3">
<i className="bi bi-hash me-1"></i>
{t('liabilities.installment')} {installment.installment_number}
</span>
<span className="me-3">
<i className="bi bi-calendar me-1"></i>
{formatDate(installment.due_date)}
{installment.days_diff > 0 && (
<span className="text-slate-500 ms-1">
({installment.days_diff} {installment.days_diff === 1 ? t('common.day') : t('common.days')})
</span>
)}
</span>
{installment.status === 'overdue' && (
<span className="badge bg-danger">{t('liabilities.overdue')}</span>
)}
</div>
</div>
<div className="text-end">
<div className="text-danger fs-5">
{formatCurrency(installment.installment_amount, selectedCurrency)}
</div>
{installment.amount_diff > 0 && (
<small className="text-slate-500">
{t('common.difference')}: {formatCurrency(installment.amount_diff, selectedCurrency)}
</small>
)}
{installment.has_overpayment && (
<div className="mt-1">
<small className="text-warning">
<i className="bi bi-exclamation-triangle me-1"></i>
{t('transactions.overpayment')}: +{formatCurrency(installment.overpayment, selectedCurrency)}
</small>
</div>
)}
{reconcileLiabilityData.selectedInstallmentId === installment.id && (
<i className="bi bi-check-circle-fill text-success mt-1 d-block"></i>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Botões de ação */}
<div className="d-flex justify-content-end gap-2 mt-4">
<button className="btn btn-secondary" onClick={() => setShowReconcileLiabilityModal(false)}>
{t('common.cancel')}
</button>
<button
className="btn btn-primary"
onClick={handleReconcileWithLiability}
disabled={!reconcileLiabilityData.selectedInstallmentId}
>
<i className="bi bi-link-45deg me-1"></i>
{t('transactions.reconcile')}
</button>
</div>
</div>
)}
</Modal>
{/* Modal de Criar Recorrência */}
<CreateRecurrenceModal
show={showRecurrenceModal}
onClose={() => setShowRecurrenceModal(false)}
transaction={recurrenceTransaction}
onSuccess={handleRecurrenceSuccess}
/>
{/* Modal de Criação Rápida de Categoria */}
<Modal
show={showQuickCategoryModal}
onClose={() => setShowQuickCategoryModal(false)}
title={t('categories.newCategory')}
size="md"
>
<form onSubmit={handleQuickCategorySubmit}>
<div className="mb-3">
<label className="form-label">{t('categories.categoryName')} *</label>
<input
type="text"
className="form-control"
value={quickCategoryData.name}
onChange={(e) => setQuickCategoryData(prev => ({ ...prev, name: e.target.value }))}
required
autoFocus
placeholder={t('categories.categoryName')}
/>
</div>
<div className="mb-3">
<label className="form-label">{t('categories.parentCategory')}</label>
<select
className="form-select"
value={quickCategoryData.parent_id}
onChange={(e) => setQuickCategoryData(prev => ({ ...prev, parent_id: e.target.value }))}
>
<option value="">{t('categories.noParent')}</option>
{parentCategories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<small className="text-muted">{t('transactions.quickCategory.parentHelp')}</small>
</div>
<div className="row mb-3">
<div className="col-6">
<label className="form-label">{t('transactions.type.label')}</label>
<select
className="form-select"
value={quickCategoryData.type}
onChange={(e) => setQuickCategoryData(prev => ({ ...prev, type: e.target.value }))}
>
<option value="expense">{t('categories.types.expense')}</option>
<option value="income">{t('categories.types.income')}</option>
<option value="both">{t('categories.types.both')}</option>
</select>
</div>
<div className="col-6">
<label className="form-label">{t('common.icon')}</label>
<IconSelector
value={quickCategoryData.icon}
onChange={(icon) => setQuickCategoryData(prev => ({ ...prev, icon }))}
type="category"
/>
</div>
</div>
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => setShowQuickCategoryModal(false)}>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-primary" disabled={savingQuickItem}>
{savingQuickItem ? (
<><span className="spinner-border spinner-border-sm me-1"></span>{t('common.saving')}</>
) : (
<><i className="bi bi-check-lg me-1"></i>{t('common.create')}</>
)}
</button>
</div>
</form>
</Modal>
{/* Modal de Criação Rápida de Centro de Custo */}
<Modal
show={showQuickCostCenterModal}
onClose={() => setShowQuickCostCenterModal(false)}
title={t('costCenters.newCostCenter')}
size="md"
>
<form onSubmit={handleQuickCostCenterSubmit}>
<div className="mb-3">
<label className="form-label">{t('costCenters.costCenterName')} *</label>
<input
type="text"
className="form-control"
value={quickCostCenterData.name}
onChange={(e) => setQuickCostCenterData(prev => ({ ...prev, name: e.target.value }))}
required
autoFocus
placeholder={t('costCenters.costCenterName')}
/>
</div>
<div className="mb-3">
<label className="form-label">{t('costCenters.code')}</label>
<input
type="text"
className="form-control"
value={quickCostCenterData.code}
onChange={(e) => setQuickCostCenterData(prev => ({ ...prev, code: e.target.value }))}
placeholder={t('transactions.quickCostCenter.codePlaceholder')}
/>
<small className="text-muted">{t('transactions.quickCostCenter.codeHelp')}</small>
</div>
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => setShowQuickCostCenterModal(false)}>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-primary" disabled={savingQuickItem}>
{savingQuickItem ? (
<><span className="spinner-border spinner-border-sm me-1"></span>{t('common.saving')}</>
) : (
<><i className="bi bi-check-lg me-1"></i>{t('common.create')}</>
)}
</button>
</div>
</form>
</Modal>
{/* Modal de Categorização Rápida Individual */}
<Modal
show={showQuickCategorizeModal}
onClose={() => setShowQuickCategorizeModal(false)}
title={t('transactions.quickCategorize')}
size="md"
>
<form onSubmit={handleQuickCategorizeSubmit}>
{quickCategorizeData.transaction && (
<div className="alert alert-light mb-3">
<div className="d-flex justify-content-between align-items-center">
<div>
<strong>{quickCategorizeData.transaction.description}</strong>
{quickCategorizeData.transaction.original_description &&
quickCategorizeData.transaction.original_description !== quickCategorizeData.transaction.description && (
<div className="small text-muted mt-1">
<i className="bi bi-bank me-1"></i>
{quickCategorizeData.transaction.original_description}
</div>
)}
</div>
<span className={`badge ${quickCategorizeData.transaction.type === 'credit' ? 'bg-success' : 'bg-danger'}`}>
{quickCategorizeData.transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(quickCategorizeData.transaction.amount || quickCategorizeData.transaction.planned_amount, selectedCurrency)}
</span>
</div>
</div>
)}
<div className="mb-3">
<label className="form-label">{t('transactions.category')}</label>
<CategorySelector
categories={categories}
value={quickCategorizeData.category_id}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, category_id: e.target.value }))}
placeholder={t('transactions.selectCategory')}
/>
</div>
<div className="mb-3">
<label className="form-label">{t('transactions.costCenter')}</label>
<select
className="form-select"
value={quickCategorizeData.cost_center_id}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, cost_center_id: e.target.value }))}
>
<option value="">{t('transactions.selectCostCenter')}</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
</div>
{quickCategorizeData.transaction?.original_description && (
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input"
id="quickCategorizeAddKeyword"
checked={quickCategorizeData.add_keyword}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, add_keyword: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="quickCategorizeAddKeyword">
{t('transactions.addKeywordForFuture')}
</label>
<div className="form-text">{t('transactions.keywordHelp')}</div>
</div>
)}
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => setShowQuickCategorizeModal(false)}>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-success" disabled={savingQuickCategorize}>
{savingQuickCategorize ? (
<><span className="spinner-border spinner-border-sm me-1"></span>{t('common.saving')}</>
) : (
<><i className="bi bi-tags me-1"></i>{t('transactions.categorize')}</>
)}
</button>
</div>
</form>
</Modal>
</div>
);
}