- 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
3245 lines
141 KiB
JavaScript
Executable File
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>
|
|
);
|
|
}
|