- Modal fullscreen mobile, xl desktop - Summary cards: Grid 2x2 mobile, fontes compactas - Taxas: Grid 3 colunas com labels curtos (Mensal/Anual/Total) - Progress bar: 12px mobile, fontes reduzidas - Parcelas: Cards layout mobile substituindo tabela - Cards incluem: status, valores, juros, taxas, reconciliação, botões - Footer: Botão full-width mobile - i18n: Adicionadas traduções 'monthly', 'annual', 'total'
1473 lines
69 KiB
JavaScript
1473 lines
69 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { liabilityAccountService } from '../services/api';
|
||
import { useToast } from '../components/Toast';
|
||
import { ConfirmModal } from '../components/Modal';
|
||
import { useFormatters } from '../hooks';
|
||
|
||
const LiabilityAccounts = () => {
|
||
const { t } = useTranslation();
|
||
const toast = useToast();
|
||
const { currency: formatCurrency } = useFormatters();
|
||
|
||
// Mobile detection
|
||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||
|
||
useEffect(() => {
|
||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||
window.addEventListener('resize', handleResize);
|
||
return () => window.removeEventListener('resize', handleResize);
|
||
}, []);
|
||
|
||
// States
|
||
const [accounts, setAccounts] = useState([]);
|
||
const [summary, setSummary] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showImportModal, setShowImportModal] = useState(false);
|
||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||
const [showReconcileModal, setShowReconcileModal] = useState(false);
|
||
const [selectedAccount, setSelectedAccount] = useState(null);
|
||
const [selectedInstallment, setSelectedInstallment] = useState(null);
|
||
const [eligibleTransactions, setEligibleTransactions] = useState([]);
|
||
const [loadingTransactions, setLoadingTransactions] = useState(false);
|
||
const [reconcileSearch, setReconcileSearch] = useState('');
|
||
const [markAsPaidOnReconcile, setMarkAsPaidOnReconcile] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [filter, setFilter] = useState({ status: '', is_active: '' });
|
||
const [showPriceAnalysisModal, setShowPriceAnalysisModal] = useState(false);
|
||
|
||
// Import form state
|
||
const [importForm, setImportForm] = useState({
|
||
file: null,
|
||
name: '',
|
||
creditor: '',
|
||
contract_number: '',
|
||
currency: 'EUR',
|
||
description: '',
|
||
});
|
||
|
||
useEffect(() => {
|
||
loadAccounts();
|
||
}, [filter]);
|
||
|
||
const loadAccounts = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const params = {};
|
||
if (filter.status) params.status = filter.status;
|
||
if (filter.is_active !== '') params.is_active = filter.is_active;
|
||
|
||
const response = await liabilityAccountService.getAll(params);
|
||
if (response.success) {
|
||
setAccounts(response.data);
|
||
setSummary(response.summary);
|
||
}
|
||
} catch (error) {
|
||
toast.error(t('liabilities.loadError'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleOpenImportModal = () => {
|
||
setImportForm({
|
||
file: null,
|
||
name: '',
|
||
creditor: '',
|
||
contract_number: '',
|
||
currency: 'EUR',
|
||
description: '',
|
||
});
|
||
setShowImportModal(true);
|
||
};
|
||
|
||
const handleCloseImportModal = () => {
|
||
setShowImportModal(false);
|
||
};
|
||
|
||
const handleImportChange = (e) => {
|
||
const { name, value, files } = e.target;
|
||
if (name === 'file') {
|
||
setImportForm(prev => ({ ...prev, file: files[0] }));
|
||
} else {
|
||
setImportForm(prev => ({ ...prev, [name]: value }));
|
||
}
|
||
};
|
||
|
||
const handleImportSubmit = async (e) => {
|
||
e.preventDefault();
|
||
|
||
if (!importForm.file) {
|
||
toast.error(t('liabilities.selectFile'));
|
||
return;
|
||
}
|
||
|
||
if (!importForm.name.trim()) {
|
||
toast.error(t('validation.required'));
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', importForm.file);
|
||
formData.append('name', importForm.name);
|
||
formData.append('creditor', importForm.creditor);
|
||
formData.append('contract_number', importForm.contract_number);
|
||
formData.append('currency', importForm.currency);
|
||
formData.append('description', importForm.description);
|
||
|
||
const response = await liabilityAccountService.import(formData);
|
||
|
||
if (response.success) {
|
||
toast.success(t('liabilities.importSuccess', { count: response.imported_installments }));
|
||
handleCloseImportModal();
|
||
loadAccounts();
|
||
// Abrir detalhes do contrato importado
|
||
setSelectedAccount(response.data);
|
||
setShowDetailModal(true);
|
||
}
|
||
} catch (error) {
|
||
toast.error(error.response?.data?.message || t('liabilities.importError'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleOpenDetail = async (account) => {
|
||
try {
|
||
const response = await liabilityAccountService.getById(account.id);
|
||
if (response.success) {
|
||
setSelectedAccount(response.data);
|
||
setShowDetailModal(true);
|
||
}
|
||
} catch (error) {
|
||
toast.error(t('liabilities.loadError'));
|
||
}
|
||
};
|
||
|
||
const handleCloseDetail = () => {
|
||
setShowDetailModal(false);
|
||
setSelectedAccount(null);
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!selectedAccount) return;
|
||
|
||
setSaving(true);
|
||
try {
|
||
const response = await liabilityAccountService.delete(selectedAccount.id);
|
||
if (response.success) {
|
||
toast.success(t('liabilities.deleteSuccess'));
|
||
setShowDeleteModal(false);
|
||
setSelectedAccount(null);
|
||
loadAccounts();
|
||
}
|
||
} catch (error) {
|
||
toast.error(t('liabilities.deleteError'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleMarkInstallmentPaid = async (installment) => {
|
||
try {
|
||
const response = await liabilityAccountService.updateInstallment(
|
||
selectedAccount.id,
|
||
installment.id,
|
||
{ status: 'paid' }
|
||
);
|
||
|
||
if (response.success) {
|
||
toast.success(t('liabilities.installmentPaid'));
|
||
// Recarregar conta atualizada
|
||
const accountResponse = await liabilityAccountService.getById(selectedAccount.id);
|
||
if (accountResponse.success) {
|
||
setSelectedAccount(accountResponse.data);
|
||
}
|
||
loadAccounts();
|
||
}
|
||
} catch (error) {
|
||
toast.error(t('liabilities.updateError'));
|
||
}
|
||
};
|
||
|
||
// ============================================
|
||
// Conciliação de Parcelas
|
||
// ============================================
|
||
|
||
const handleOpenReconcileModal = async (installment) => {
|
||
setSelectedInstallment(installment);
|
||
setReconcileSearch('');
|
||
setMarkAsPaidOnReconcile(true);
|
||
setShowReconcileModal(true);
|
||
await loadEligibleTransactions(installment);
|
||
};
|
||
|
||
const handleCloseReconcileModal = () => {
|
||
setShowReconcileModal(false);
|
||
setSelectedInstallment(null);
|
||
setEligibleTransactions([]);
|
||
};
|
||
|
||
const loadEligibleTransactions = async (installment, search = '') => {
|
||
setLoadingTransactions(true);
|
||
try {
|
||
const params = {};
|
||
if (search) params.search = search;
|
||
|
||
const response = await liabilityAccountService.getEligibleTransactions(
|
||
selectedAccount.id,
|
||
installment.id,
|
||
params
|
||
);
|
||
|
||
if (response.success) {
|
||
setEligibleTransactions(response.data);
|
||
}
|
||
} catch (error) {
|
||
toast.error(t('liabilities.loadError'));
|
||
} finally {
|
||
setLoadingTransactions(false);
|
||
}
|
||
};
|
||
|
||
const handleReconcileSearch = async (e) => {
|
||
const value = e.target.value;
|
||
setReconcileSearch(value);
|
||
|
||
// Debounce search
|
||
if (value.length >= 2 || value.length === 0) {
|
||
await loadEligibleTransactions(selectedInstallment, value);
|
||
}
|
||
};
|
||
|
||
const handleReconcile = async (transaction) => {
|
||
setSaving(true);
|
||
try {
|
||
const response = await liabilityAccountService.reconcile(
|
||
selectedAccount.id,
|
||
selectedInstallment.id,
|
||
transaction.id,
|
||
markAsPaidOnReconcile
|
||
);
|
||
|
||
if (response.success) {
|
||
toast.success(t('liabilities.reconcileSuccess'));
|
||
handleCloseReconcileModal();
|
||
|
||
// Recarregar conta atualizada
|
||
const accountResponse = await liabilityAccountService.getById(selectedAccount.id);
|
||
if (accountResponse.success) {
|
||
setSelectedAccount(accountResponse.data);
|
||
}
|
||
loadAccounts();
|
||
}
|
||
} catch (error) {
|
||
toast.error(error.response?.data?.message || t('liabilities.reconcileError'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleUnreconcile = async (installment) => {
|
||
if (!confirm(t('liabilities.unreconcile') + '?')) return;
|
||
|
||
setSaving(true);
|
||
try {
|
||
const response = await liabilityAccountService.unreconcile(
|
||
selectedAccount.id,
|
||
installment.id
|
||
);
|
||
|
||
if (response.success) {
|
||
toast.success(t('liabilities.unreconcileSuccess'));
|
||
|
||
// Recarregar conta atualizada
|
||
const accountResponse = await liabilityAccountService.getById(selectedAccount.id);
|
||
if (accountResponse.success) {
|
||
setSelectedAccount(accountResponse.data);
|
||
}
|
||
loadAccounts();
|
||
}
|
||
} catch (error) {
|
||
toast.error(t('liabilities.unreconcileError'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const formatPercent = (value) => {
|
||
if (value === null || value === undefined) return '-';
|
||
return `${parseFloat(value).toFixed(2)}%`;
|
||
};
|
||
|
||
const formatDate = (dateString) => {
|
||
if (!dateString) return '-';
|
||
return new Date(dateString).toLocaleDateString();
|
||
};
|
||
|
||
const getStatusBadge = (status) => {
|
||
const badges = {
|
||
active: 'bg-primary',
|
||
paid_off: 'bg-success',
|
||
defaulted: 'bg-danger',
|
||
renegotiated: 'bg-warning text-dark',
|
||
pending: 'bg-secondary',
|
||
paid: 'bg-success',
|
||
partial: 'bg-info',
|
||
overdue: 'bg-danger',
|
||
cancelled: 'bg-dark',
|
||
};
|
||
return badges[status] || 'bg-secondary';
|
||
};
|
||
|
||
const getStatusLabel = (status, isInstallment = false) => {
|
||
const labels = isInstallment
|
||
? liabilityAccountService.installmentStatuses
|
||
: liabilityAccountService.statuses;
|
||
return labels[status] || status;
|
||
};
|
||
|
||
// Calcular totais por moeda
|
||
const getTotalsByCurrency = () => {
|
||
const totals = {};
|
||
accounts.forEach(account => {
|
||
const currency = account.currency || 'EUR';
|
||
if (!totals[currency]) {
|
||
totals[currency] = {
|
||
principal: 0,
|
||
paid: 0,
|
||
pending: 0,
|
||
interest: 0,
|
||
};
|
||
}
|
||
totals[currency].principal += parseFloat(account.principal_amount) || 0;
|
||
totals[currency].paid += parseFloat(account.total_paid) || 0;
|
||
totals[currency].pending += parseFloat(account.total_pending) || 0;
|
||
totals[currency].interest += parseFloat(account.total_interest) || 0;
|
||
});
|
||
return totals;
|
||
};
|
||
|
||
const totalsByCurrency = getTotalsByCurrency();
|
||
|
||
return (
|
||
<div className="container-fluid py-3">
|
||
{/* Header */}
|
||
<div className={`d-flex ${isMobile ? 'flex-column gap-2' : 'justify-content-between align-items-center'} mb-4`}>
|
||
<div>
|
||
<h4 className="mb-1" style={{ fontSize: isMobile ? '1.1rem' : '1.5rem' }}>
|
||
<i className="bi bi-file-earmark-text me-2"></i>
|
||
{t('liabilities.title')}
|
||
</h4>
|
||
<small className="text-muted" style={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}>
|
||
{t('liabilities.subtitle')}
|
||
</small>
|
||
</div>
|
||
<button
|
||
className={`btn btn-primary ${isMobile ? 'w-100' : ''}`}
|
||
onClick={handleOpenImportModal}
|
||
>
|
||
<i className="bi bi-upload me-2"></i>
|
||
{isMobile ? t('common.import') : t('liabilities.importContract')}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Summary Cards */}
|
||
{Object.keys(totalsByCurrency).length > 0 && (
|
||
<div className="row g-3 mb-4">
|
||
{Object.entries(totalsByCurrency).map(([currency, totals]) => (
|
||
<React.Fragment key={currency}>
|
||
<div className={isMobile ? "col-6" : "col-md-3"}>
|
||
<div className="card bg-danger text-white h-100">
|
||
<div className="card-body" style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
<div className="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div className="small opacity-75">{t('liabilities.totalDebt')} ({currency})</div>
|
||
<div className="h5 mb-0">{formatCurrency(totals.principal, currency)}</div>
|
||
</div>
|
||
<i className="bi bi-cash-stack fs-2 opacity-50"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="card bg-success text-white h-100">
|
||
<div className="card-body py-3">
|
||
<div className="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div className="small opacity-75">{t('liabilities.totalPaid')} ({currency})</div>
|
||
<div className="h5 mb-0">{formatCurrency(totals.paid, currency)}</div>
|
||
</div>
|
||
<i className="bi bi-check-circle fs-2 opacity-50"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="card bg-warning text-dark h-100">
|
||
<div className="card-body py-3">
|
||
<div className="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div className="small opacity-75">{t('liabilities.totalPending')} ({currency})</div>
|
||
<div className="h5 mb-0">{formatCurrency(totals.pending, currency)}</div>
|
||
</div>
|
||
<i className="bi bi-hourglass-split fs-2 opacity-50"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="card bg-info text-white h-100">
|
||
<div className="card-body py-3">
|
||
<div className="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div className="small opacity-75">{t('liabilities.totalInterest')} ({currency})</div>
|
||
<div className="h5 mb-0">{formatCurrency(totals.interest, currency)}</div>
|
||
</div>
|
||
<i className="bi bi-percent fs-2 opacity-50"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div className="card mb-4">
|
||
<div className="card-body" style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
<div className={`row g-2 ${isMobile ? '' : 'align-items-center'}`}>
|
||
{!isMobile && (
|
||
<div className="col-auto">
|
||
<label className="col-form-label">{t('common.filter')}:</label>
|
||
</div>
|
||
)}
|
||
<div className={isMobile ? "col-6" : "col-auto"}>
|
||
<select
|
||
className="form-select form-select-sm"
|
||
value={filter.status}
|
||
onChange={(e) => setFilter(prev => ({ ...prev, status: e.target.value }))}
|
||
style={{ fontSize: isMobile ? '0.8rem' : '0.875rem' }}
|
||
>
|
||
<option value="">{t('liabilities.allStatuses')}</option>
|
||
{Object.entries(liabilityAccountService.statuses).map(([key, label]) => (
|
||
<option key={key} value={key}>{label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className={isMobile ? "col-6" : "col-auto"}>
|
||
<select
|
||
className="form-select form-select-sm"
|
||
value={filter.is_active}
|
||
onChange={(e) => setFilter(prev => ({ ...prev, is_active: e.target.value }))}
|
||
style={{ fontSize: isMobile ? '0.8rem' : '0.875rem' }}
|
||
>
|
||
<option value="">{t('common.all')}</option>
|
||
<option value="1">{t('common.active')}</option>
|
||
<option value="0">{t('common.inactive')}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Contracts List */}
|
||
{loading ? (
|
||
<div className="text-center py-5">
|
||
<div className="spinner-border" role="status">
|
||
<span className="visually-hidden">{t('common.loading')}</span>
|
||
</div>
|
||
</div>
|
||
) : accounts.length === 0 ? (
|
||
<div className="card">
|
||
<div className="card-body text-center py-5">
|
||
<i className="bi bi-inbox fs-1 text-muted mb-3 d-block"></i>
|
||
<h5 className="text-muted">{t('liabilities.noContracts')}</h5>
|
||
<p className="text-muted mb-3">{t('liabilities.importHint')}</p>
|
||
<button className="btn btn-primary" onClick={handleOpenImportModal}>
|
||
<i className="bi bi-upload me-2"></i>
|
||
{t('liabilities.importContract')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="row g-3">
|
||
{accounts.map(account => (
|
||
<div key={account.id} className={isMobile ? "col-12" : "col-md-6 col-lg-4"}>
|
||
<div className="card h-100">
|
||
<div className="card-header d-flex justify-content-between align-items-center"
|
||
style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
<div className="d-flex align-items-center" style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}>
|
||
<i className={`${account.icon || 'bi-file-earmark-text'} me-2`}
|
||
style={{ color: account.color || '#DC2626', fontSize: isMobile ? '0.9rem' : '1rem' }}></i>
|
||
<strong style={{ fontSize: isMobile ? '0.85rem' : '1rem' }} className="text-truncate">
|
||
{account.name}
|
||
</strong>
|
||
</div>
|
||
<span className={`badge ${getStatusBadge(account.status)}`}
|
||
style={{ fontSize: isMobile ? '0.65rem' : '0.75rem', flexShrink: 0 }}>
|
||
{getStatusLabel(account.status)}
|
||
</span>
|
||
</div>
|
||
<div className="card-body" style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
{account.creditor && (
|
||
<div className="text-muted mb-2" style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
|
||
<i className="bi bi-building me-1"></i>
|
||
{account.creditor}
|
||
</div>
|
||
)}
|
||
|
||
{/* Progress Bar */}
|
||
<div className="mb-3">
|
||
<div className="d-flex justify-content-between mb-1"
|
||
style={{ fontSize: isMobile ? '0.7rem' : '0.875rem' }}>
|
||
<span>{t('liabilities.progress')}</span>
|
||
<span className="fw-bold">{account.progress_percentage || 0}%</span>
|
||
</div>
|
||
<div className="progress" style={{ height: isMobile ? '6px' : '8px' }}>
|
||
<div
|
||
className="progress-bar bg-success"
|
||
style={{ width: `${account.progress_percentage || 0}%` }}
|
||
></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Values */}
|
||
<div className="row g-2" style={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}>
|
||
<div className="col-6">
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.75rem' }}>
|
||
{t('liabilities.principal')}
|
||
</div>
|
||
<div className="fw-bold">{formatCurrency(account.principal_amount, account.currency)}</div>
|
||
</div>
|
||
<div className="col-6">
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.75rem' }}>
|
||
{t('liabilities.remaining')}
|
||
</div>
|
||
<div className="fw-bold text-danger">{formatCurrency(account.remaining_balance, account.currency)}</div>
|
||
</div>
|
||
<div className="col-6">
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.75rem' }}>
|
||
{t('liabilities.installments')}
|
||
</div>
|
||
<div className="fw-bold">{account.paid_installments}/{account.total_installments}</div>
|
||
</div>
|
||
<div className="col-6">
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.75rem' }}>
|
||
{t('liabilities.monthlyRate')}
|
||
</div>
|
||
<div className="fw-bold">{formatPercent(account.monthly_interest_rate)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-footer bg-transparent" style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
<div className="d-flex gap-2">
|
||
<button
|
||
className="btn btn-sm btn-outline-primary flex-grow-1"
|
||
onClick={() => handleOpenDetail(account)}
|
||
style={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
|
||
>
|
||
<i className="bi bi-eye me-1"></i>
|
||
{t('common.details')}
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-outline-danger"
|
||
onClick={() => {
|
||
setSelectedAccount(account);
|
||
setShowDeleteModal(true);
|
||
}}
|
||
style={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}
|
||
>
|
||
<i className="bi bi-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Import Modal */}
|
||
{showImportModal && (
|
||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||
<div className="modal-dialog modal-lg">
|
||
<div className="modal-content">
|
||
<div className="modal-header">
|
||
<h5 className="modal-title">
|
||
<i className="bi bi-upload me-2"></i>
|
||
{t('liabilities.importContract')}
|
||
</h5>
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={handleCloseImportModal}
|
||
disabled={saving}
|
||
></button>
|
||
</div>
|
||
<form onSubmit={handleImportSubmit}>
|
||
<div className="modal-body">
|
||
<div className="alert alert-info">
|
||
<i className="bi bi-info-circle me-2"></i>
|
||
{t('liabilities.importInfo')}
|
||
</div>
|
||
|
||
<div className="row g-3">
|
||
<div className="col-12">
|
||
<label className="form-label">{t('liabilities.excelFile')} *</label>
|
||
<input
|
||
type="file"
|
||
className="form-control"
|
||
name="file"
|
||
accept=".xlsx,.xls"
|
||
onChange={handleImportChange}
|
||
required
|
||
/>
|
||
<div className="form-text">{t('liabilities.fileFormatHint')}</div>
|
||
</div>
|
||
|
||
<div className="col-md-8">
|
||
<label className="form-label">{t('liabilities.contractName')} *</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
name="name"
|
||
value={importForm.name}
|
||
onChange={handleImportChange}
|
||
placeholder={t('liabilities.contractNamePlaceholder')}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-md-4">
|
||
<label className="form-label">{t('common.currency')}</label>
|
||
<select
|
||
className="form-select"
|
||
name="currency"
|
||
value={importForm.currency}
|
||
onChange={handleImportChange}
|
||
>
|
||
<option value="EUR">EUR - Euro</option>
|
||
<option value="BRL">BRL - Real</option>
|
||
<option value="USD">USD - Dólar</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="col-md-6">
|
||
<label className="form-label">{t('liabilities.creditor')}</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
name="creditor"
|
||
value={importForm.creditor}
|
||
onChange={handleImportChange}
|
||
placeholder={t('liabilities.creditorPlaceholder')}
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-md-6">
|
||
<label className="form-label">{t('liabilities.contractNumber')}</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
name="contract_number"
|
||
value={importForm.contract_number}
|
||
onChange={handleImportChange}
|
||
placeholder={t('liabilities.contractNumberPlaceholder')}
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-12">
|
||
<label className="form-label">{t('common.description')}</label>
|
||
<textarea
|
||
className="form-control"
|
||
name="description"
|
||
value={importForm.description}
|
||
onChange={handleImportChange}
|
||
rows="2"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={handleCloseImportModal}
|
||
disabled={saving}
|
||
>
|
||
{t('common.cancel')}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn btn-primary"
|
||
disabled={saving}
|
||
>
|
||
{saving ? (
|
||
<>
|
||
<span className="spinner-border spinner-border-sm me-2"></span>
|
||
{t('common.importing')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<i className="bi bi-upload me-2"></i>
|
||
{t('liabilities.import')}
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Detail Modal */}
|
||
{showDetailModal && selectedAccount && (
|
||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||
<div className={`modal-dialog ${isMobile ? 'modal-fullscreen' : 'modal-xl'} modal-dialog-scrollable`}>
|
||
<div className="modal-content">
|
||
<div className="modal-header" style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
<h5 className="modal-title d-flex align-items-center" style={{ fontSize: isMobile ? '0.95rem' : '1.25rem' }}>
|
||
<i className={`${selectedAccount.icon || 'bi-file-earmark-text'} me-2`}
|
||
style={{ color: selectedAccount.color, fontSize: isMobile ? '0.9rem' : '1rem' }}></i>
|
||
<span className="text-truncate">{selectedAccount.name}</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-link text-info p-0 ms-2"
|
||
onClick={() => setShowPriceAnalysisModal(true)}
|
||
title={t('liabilities.priceAnalysis')}
|
||
>
|
||
<i className="bi bi-info-circle fs-5"></i>
|
||
</button>
|
||
</h5>
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={handleCloseDetail}
|
||
></button>
|
||
</div>
|
||
<div className="modal-body" style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
{/* Contract Type / Description */}
|
||
{selectedAccount.description && (
|
||
<div className="alert alert-info mb-3" style={{ fontSize: isMobile ? '0.75rem' : '0.875rem', padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<i className="bi bi-info-circle me-2"></i>
|
||
<strong>{t('liabilities.contractType')}:</strong> {selectedAccount.description}
|
||
</div>
|
||
)}
|
||
|
||
{/* Summary Cards */}
|
||
<div className="row g-2 mb-3">
|
||
<div className={isMobile ? "col-6" : "col-md-3"}>
|
||
<div className="card bg-light">
|
||
<div className="card-body text-center" style={{ padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.875rem' }}>{t('liabilities.principal')}</div>
|
||
<div className="mb-0 fw-bold" style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={isMobile ? "col-6" : "col-md-3"}>
|
||
<div className="card bg-light">
|
||
<div className="card-body text-center" style={{ padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.875rem' }}>{t('liabilities.totalInterest')}</div>
|
||
<div className="mb-0 fw-bold" style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={isMobile ? "col-6" : "col-md-3"}>
|
||
<div className="card bg-light">
|
||
<div className="card-body text-center" style={{ padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.875rem' }}>{t('liabilities.totalFees')}</div>
|
||
<div className="mb-0 text-warning fw-bold" style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>{formatCurrency(selectedAccount.total_fees, selectedAccount.currency)}</div>
|
||
{!isMobile && <div className="small text-muted">{t('liabilities.extraCharges')}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={isMobile ? "col-6" : "col-md-3"}>
|
||
<div className="card bg-light">
|
||
<div className="card-body text-center" style={{ padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.65rem' : '0.875rem' }}>{t('liabilities.totalContract')}</div>
|
||
<div className="mb-0 fw-bold" style={{ fontSize: isMobile ? '0.85rem' : '1.25rem' }}>{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Interest Rates */}
|
||
<div className="row g-2 mb-3">
|
||
<div className={isMobile ? "col-4" : "col-md-4"}>
|
||
<div className="card border-info">
|
||
<div className="card-body text-center" style={{ padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.6rem' : '0.875rem' }}>{isMobile ? t('liabilities.monthly') : t('liabilities.monthlyRate')}</div>
|
||
<div className="mb-0 text-info fw-bold" style={{ fontSize: isMobile ? '0.9rem' : '1.5rem' }}>{formatPercent(selectedAccount.monthly_interest_rate)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={isMobile ? "col-4" : "col-md-4"}>
|
||
<div className="card border-info">
|
||
<div className="card-body text-center" style={{ padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.6rem' : '0.875rem' }}>{isMobile ? t('liabilities.annual') : t('liabilities.annualRate')}</div>
|
||
<div className="mb-0 text-info fw-bold" style={{ fontSize: isMobile ? '0.9rem' : '1.5rem' }}>{formatPercent(selectedAccount.annual_interest_rate)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={isMobile ? "col-4" : "col-md-4"}>
|
||
<div className="card border-info">
|
||
<div className="card-body text-center" style={{ padding: isMobile ? '0.5rem' : '0.75rem' }}>
|
||
<div className="text-muted" style={{ fontSize: isMobile ? '0.6rem' : '0.875rem' }}>{isMobile ? t('liabilities.total') : t('liabilities.totalRate')}</div>
|
||
<div className="mb-0 text-info fw-bold" style={{ fontSize: isMobile ? '0.9rem' : '1.5rem' }}>{formatPercent(selectedAccount.total_interest_rate)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress */}
|
||
<div className="mb-3">
|
||
<div className="d-flex justify-content-between mb-2" style={{ fontSize: isMobile ? '0.75rem' : '0.875rem' }}>
|
||
<span>{t('liabilities.paymentProgress')}</span>
|
||
<span className="fw-bold">{selectedAccount.paid_installments}/{selectedAccount.total_installments} {isMobile ? '' : t('liabilities.installments').toLowerCase()}</span>
|
||
</div>
|
||
<div className="progress" style={{ height: isMobile ? '12px' : '20px' }}>
|
||
<div
|
||
className="progress-bar bg-success"
|
||
style={{ width: `${selectedAccount.progress_percentage || 0}%`, fontSize: isMobile ? '0.65rem' : '0.75rem' }}
|
||
>
|
||
{selectedAccount.progress_percentage}%
|
||
</div>
|
||
</div>
|
||
<div className="d-flex justify-content-between text-muted mt-1" style={{ fontSize: isMobile ? '0.65rem' : '0.875rem' }}>
|
||
<span>{t('liabilities.paid')}: {formatCurrency(selectedAccount.principal_paid, selectedAccount.currency)}</span>
|
||
<span>{t('liabilities.remaining')}: {formatCurrency(selectedAccount.remaining_balance, selectedAccount.currency)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Installments Table */}
|
||
<h6 className="mb-3" style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}>
|
||
<i className="bi bi-list-ol me-2"></i>
|
||
{t('liabilities.installmentsList')}
|
||
</h6>
|
||
|
||
{isMobile ? (
|
||
/* Mobile: Cards layout */
|
||
<div className="d-flex flex-column gap-2">
|
||
{selectedAccount.installments?.map(inst => (
|
||
<div
|
||
key={inst.id}
|
||
className={`card border ${inst.status === 'paid' ? 'border-success bg-success bg-opacity-10' : inst.is_overdue ? 'border-danger bg-danger bg-opacity-10' : 'border-secondary'}`}
|
||
>
|
||
<div className="card-body p-2">
|
||
{/* Header */}
|
||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||
<div className="fw-bold" style={{ fontSize: '0.85rem' }}>
|
||
<i className="bi bi-hash"></i>{inst.installment_number} - {formatDate(inst.due_date)}
|
||
</div>
|
||
<span className={`badge ${getStatusBadge(inst.status)}`} style={{ fontSize: '0.65rem' }}>
|
||
{getStatusLabel(inst.status, true)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Values */}
|
||
<div className="row g-1 mb-2" style={{ fontSize: '0.7rem' }}>
|
||
<div className="col-6">
|
||
<div className="text-muted" style={{ fontSize: '0.6rem' }}>{t('liabilities.installmentAmount')}</div>
|
||
<div className="fw-bold">{formatCurrency(inst.installment_amount, selectedAccount.currency)}</div>
|
||
</div>
|
||
{inst.paid_amount > 0 && (
|
||
<div className="col-6">
|
||
<div className="text-muted" style={{ fontSize: '0.6rem' }}>{t('liabilities.paidAmount')}</div>
|
||
<div className={`fw-bold ${inst.paid_amount > inst.installment_amount ? 'text-warning' : 'text-success'}`}>
|
||
{formatCurrency(inst.paid_amount, selectedAccount.currency)}
|
||
{inst.paid_amount > inst.installment_amount && <i className="bi bi-arrow-up-circle-fill ms-1"></i>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="col-4">
|
||
<div className="text-muted" style={{ fontSize: '0.6rem' }}>{t('liabilities.capital')}</div>
|
||
<div>{formatCurrency(inst.principal_amount, selectedAccount.currency)}</div>
|
||
</div>
|
||
<div className="col-4">
|
||
<div className="text-muted" style={{ fontSize: '0.6rem' }}>{t('liabilities.interest')}</div>
|
||
<div>{formatCurrency(inst.interest_amount, selectedAccount.currency)}</div>
|
||
</div>
|
||
{inst.fee_amount > 0 && (
|
||
<div className="col-4">
|
||
<div className="text-muted" style={{ fontSize: '0.6rem' }}>{t('liabilities.fees')}</div>
|
||
<div className="text-warning">{formatCurrency(inst.fee_amount, selectedAccount.currency)}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Reconciliation */}
|
||
{inst.reconciled_transaction_id ? (
|
||
<div className="d-flex justify-content-between align-items-center">
|
||
<span className="badge bg-info" style={{ fontSize: '0.65rem' }}>
|
||
<i className="bi bi-link-45deg me-1"></i>{t('liabilities.reconciled')}
|
||
</span>
|
||
<button
|
||
className="btn btn-sm btn-outline-warning"
|
||
onClick={() => handleUnreconcile(inst)}
|
||
disabled={saving}
|
||
style={{ fontSize: '0.7rem', padding: '0.25rem 0.5rem' }}
|
||
>
|
||
<i className="bi bi-link-45deg"></i>
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="d-flex gap-1">
|
||
{inst.status !== 'paid' && (
|
||
<button
|
||
className="btn btn-sm btn-outline-success flex-fill"
|
||
onClick={() => handleMarkInstallmentPaid(inst)}
|
||
style={{ fontSize: '0.7rem', padding: '0.25rem' }}
|
||
>
|
||
<i className="bi bi-check me-1"></i>{t('liabilities.markPaid')}
|
||
</button>
|
||
)}
|
||
<button
|
||
className="btn btn-sm btn-outline-primary flex-fill"
|
||
onClick={() => handleOpenReconcileModal(inst)}
|
||
style={{ fontSize: '0.7rem', padding: '0.25rem' }}
|
||
>
|
||
<i className="bi bi-link me-1"></i>{t('liabilities.reconcile')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
/* Desktop: Table layout */
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-hover">
|
||
<thead className="table-light">
|
||
<tr>
|
||
<th style={{ width: '50px' }}>#</th>
|
||
<th>{t('liabilities.dueDate')}</th>
|
||
<th className="text-end">{t('liabilities.installmentAmount')}</th>
|
||
<th className="text-end">{t('liabilities.paidAmount')}</th>
|
||
<th className="text-end">{t('liabilities.capital')}</th>
|
||
<th className="text-end">{t('liabilities.interest')}</th>
|
||
<th className="text-end">{t('liabilities.fees')}</th>
|
||
<th className="text-center">{t('common.status')}</th>
|
||
<th className="text-center">{t('liabilities.reconciliation')}</th>
|
||
<th style={{ width: '120px' }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{selectedAccount.installments?.map(inst => (
|
||
<tr key={inst.id} className={inst.status === 'paid' ? 'table-success' : inst.is_overdue ? 'table-danger' : ''}>
|
||
<td>{inst.installment_number}</td>
|
||
<td>{formatDate(inst.due_date)}</td>
|
||
<td className="text-end fw-bold">{formatCurrency(inst.installment_amount, selectedAccount.currency)}</td>
|
||
<td className="text-end">
|
||
{inst.paid_amount > 0 ? (
|
||
<span className={inst.paid_amount > inst.installment_amount ? 'text-warning' : ''}>
|
||
{formatCurrency(inst.paid_amount, selectedAccount.currency)}
|
||
{inst.paid_amount > inst.installment_amount && (
|
||
<i className="bi bi-arrow-up-circle-fill ms-1" title={t('transactions.overpayment')}></i>
|
||
)}
|
||
</span>
|
||
) : '-'}
|
||
</td>
|
||
<td className="text-end">{formatCurrency(inst.principal_amount, selectedAccount.currency)}</td>
|
||
<td className="text-end">{formatCurrency(inst.interest_amount, selectedAccount.currency)}</td>
|
||
<td className="text-end">{inst.fee_amount > 0 ? formatCurrency(inst.fee_amount, selectedAccount.currency) : '-'}</td>
|
||
<td className="text-center">
|
||
<span className={`badge ${getStatusBadge(inst.status)}`}>
|
||
{getStatusLabel(inst.status, true)}
|
||
</span>
|
||
</td>
|
||
<td className="text-center">
|
||
{inst.reconciled_transaction_id ? (
|
||
<span className="badge bg-info" title={t('liabilities.linkedTransaction') + `: #${inst.reconciled_transaction_id}`}>
|
||
<i className="bi bi-link-45deg me-1"></i>
|
||
{t('liabilities.reconciled')}
|
||
</span>
|
||
) : (
|
||
<span className="badge bg-secondary">
|
||
{t('liabilities.notReconciled')}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<div className="btn-group btn-group-sm">
|
||
{inst.status !== 'paid' && !inst.reconciled_transaction_id && (
|
||
<button
|
||
className="btn btn-outline-success"
|
||
onClick={() => handleMarkInstallmentPaid(inst)}
|
||
title={t('liabilities.markPaid')}
|
||
>
|
||
<i className="bi bi-check"></i>
|
||
</button>
|
||
)}
|
||
{!inst.reconciled_transaction_id ? (
|
||
<button
|
||
className="btn btn-outline-primary"
|
||
onClick={() => handleOpenReconcileModal(inst)}
|
||
title={t('liabilities.reconcile')}
|
||
>
|
||
<i className="bi bi-link"></i>
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="btn btn-outline-warning"
|
||
onClick={() => handleUnreconcile(inst)}
|
||
title={t('liabilities.unreconcile')}
|
||
disabled={saving}
|
||
>
|
||
<i className="bi bi-link-45deg"></i>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot className="table-light fw-bold">
|
||
<tr>
|
||
<td colSpan="2">{t('common.total')}</td>
|
||
<td className="text-end">{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}</td>
|
||
<td className="text-end">{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}</td>
|
||
<td className="text-end">{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}</td>
|
||
<td className="text-end">{formatCurrency(selectedAccount.total_fees, selectedAccount.currency)}</td>
|
||
<td colSpan="2"></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-footer" style={{ padding: isMobile ? '0.75rem' : '1rem' }}>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-secondary ${isMobile ? 'w-100' : ''}`}
|
||
onClick={handleCloseDetail}
|
||
style={{ fontSize: isMobile ? '0.85rem' : '1rem' }}
|
||
>
|
||
{t('common.close')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reconcile Modal */}
|
||
{showReconcileModal && selectedInstallment && (
|
||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||
<div className="modal-dialog modal-lg">
|
||
<div className="modal-content">
|
||
<div className="modal-header">
|
||
<h5 className="modal-title">
|
||
<i className="bi bi-link me-2"></i>
|
||
{t('liabilities.reconcileInstallment')}
|
||
</h5>
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={handleCloseReconcileModal}
|
||
disabled={saving}
|
||
></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
{/* Installment Info */}
|
||
<div className="alert alert-info mb-3">
|
||
<div className="row">
|
||
<div className="col-md-4">
|
||
<strong>{t('liabilities.installments')}:</strong> #{selectedInstallment.installment_number}
|
||
</div>
|
||
<div className="col-md-4">
|
||
<strong>{t('liabilities.dueDate')}:</strong> {formatDate(selectedInstallment.due_date)}
|
||
</div>
|
||
<div className="col-md-4">
|
||
<strong>{t('liabilities.installmentAmount')}:</strong> {formatCurrency(selectedInstallment.installment_amount, selectedAccount?.currency)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Options */}
|
||
<div className="mb-3">
|
||
<div className="form-check">
|
||
<input
|
||
type="checkbox"
|
||
className="form-check-input"
|
||
id="markAsPaid"
|
||
checked={markAsPaidOnReconcile}
|
||
onChange={(e) => setMarkAsPaidOnReconcile(e.target.checked)}
|
||
/>
|
||
<label className="form-check-label" htmlFor="markAsPaid">
|
||
{t('liabilities.markAsPaid')}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div className="mb-3">
|
||
<label className="form-label">{t('liabilities.searchTransactions')}</label>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
value={reconcileSearch}
|
||
onChange={handleReconcileSearch}
|
||
placeholder={t('common.search') + '...'}
|
||
/>
|
||
</div>
|
||
|
||
{/* Transactions List */}
|
||
<h6 className="mb-2">{t('liabilities.eligibleTransactions')}</h6>
|
||
|
||
{loadingTransactions ? (
|
||
<div className="text-center py-4">
|
||
<div className="spinner-border text-primary" role="status">
|
||
<span className="visually-hidden">{t('common.loading')}</span>
|
||
</div>
|
||
</div>
|
||
) : eligibleTransactions.length === 0 ? (
|
||
<div className="alert alert-warning">
|
||
{t('liabilities.noEligibleTransactions')}
|
||
</div>
|
||
) : (
|
||
<div className="table-responsive" style={{ maxHeight: '300px' }}>
|
||
<table className="table table-sm table-hover">
|
||
<thead className="table-light sticky-top">
|
||
<tr>
|
||
<th>{t('transactions.date')}</th>
|
||
<th>{t('transactions.description')}</th>
|
||
<th>{t('transactions.account')}</th>
|
||
<th className="text-end">{t('transactions.amount')}</th>
|
||
<th style={{ width: '80px' }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{eligibleTransactions.map(tx => (
|
||
<tr key={tx.id}>
|
||
<td>{formatDate(tx.effective_date || tx.planned_date)}</td>
|
||
<td>
|
||
<div className="text-truncate" style={{ maxWidth: '200px' }} title={tx.description}>
|
||
{tx.description || tx.original_description}
|
||
</div>
|
||
</td>
|
||
<td>{tx.account?.name || '-'}</td>
|
||
<td className="text-end fw-bold text-danger">
|
||
{formatCurrency(Math.abs(tx.amount), tx.account?.currency)}
|
||
</td>
|
||
<td>
|
||
<button
|
||
className="btn btn-sm btn-primary"
|
||
onClick={() => handleReconcile(tx)}
|
||
disabled={saving}
|
||
>
|
||
<i className="bi bi-link"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={handleCloseReconcileModal}
|
||
disabled={saving}
|
||
>
|
||
{t('common.cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete Confirmation Modal */}
|
||
<ConfirmModal
|
||
show={showDeleteModal}
|
||
onHide={() => setShowDeleteModal(false)}
|
||
onConfirm={handleDelete}
|
||
title={t('liabilities.deleteTitle')}
|
||
message={t('liabilities.deleteConfirm', { name: selectedAccount?.name })}
|
||
confirmText={t('common.delete')}
|
||
cancelText={t('common.cancel')}
|
||
variant="danger"
|
||
loading={saving}
|
||
/>
|
||
|
||
{/* PRICE System Analysis Modal */}
|
||
{showPriceAnalysisModal && selectedAccount && (
|
||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1060 }}>
|
||
<div className="modal-dialog modal-xl modal-dialog-scrollable">
|
||
<div className="modal-content">
|
||
<div className="modal-header bg-info text-white">
|
||
<h5 className="modal-title">
|
||
<i className="bi bi-mortarboard me-2"></i>
|
||
{t('liabilities.priceAnalysisTitle')} - {selectedAccount.name}
|
||
</h5>
|
||
<button
|
||
type="button"
|
||
className="btn-close btn-close-white"
|
||
onClick={() => setShowPriceAnalysisModal(false)}
|
||
></button>
|
||
</div>
|
||
<div className="modal-body">
|
||
{/* Contract Summary */}
|
||
<div className="alert alert-primary mb-4">
|
||
<div className="row text-center">
|
||
<div className="col-md-3">
|
||
<div className="small opacity-75">{t('liabilities.principal')}</div>
|
||
<div className="h5 mb-0">{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="small opacity-75">{t('liabilities.totalInterest')}</div>
|
||
<div className="h5 mb-0">{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="small opacity-75">{t('liabilities.installments')}</div>
|
||
<div className="h5 mb-0">{selectedAccount.total_installments}</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="small opacity-75">{t('liabilities.totalContract')}</div>
|
||
<div className="h5 mb-0">{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Overview */}
|
||
<div className="alert alert-info">
|
||
<h6 className="alert-heading">
|
||
<i className="bi bi-info-circle me-2"></i>
|
||
{t('liabilities.priceOverview')}
|
||
</h6>
|
||
<p className="mb-0">{t('liabilities.priceOverviewText')}</p>
|
||
</div>
|
||
|
||
{/* What is PRICE */}
|
||
<div className="card mb-3">
|
||
<div className="card-header bg-primary text-white">
|
||
<i className="bi bi-question-circle me-2"></i>
|
||
{t('liabilities.whatIsPrice')}
|
||
</div>
|
||
<div className="card-body">
|
||
<p>{t('liabilities.whatIsPriceText')}</p>
|
||
<ul className="mb-0">
|
||
<li><strong>{t('liabilities.priceFeature1')}</strong></li>
|
||
<li><strong>{t('liabilities.priceFeature2')}</strong></li>
|
||
<li><strong>{t('liabilities.priceFeature3')}</strong></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mathematical Formula */}
|
||
<div className="card mb-3">
|
||
<div className="card-header bg-dark text-white">
|
||
<i className="bi bi-calculator me-2"></i>
|
||
{t('liabilities.priceMathFormula')}
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="bg-light p-3 rounded text-center mb-3" style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>
|
||
PMT = PV × [i × (1 + i)ⁿ] / [(1 + i)ⁿ - 1]
|
||
</div>
|
||
<div className="row">
|
||
<div className="col-md-6">
|
||
<p className="mb-2"><strong>{t('liabilities.priceWhere')}:</strong></p>
|
||
<ul className="small">
|
||
<li><code>PMT</code> = {t('liabilities.pricePMT')}</li>
|
||
<li><code>PV</code> = {t('liabilities.pricePV')}</li>
|
||
<li><code>i</code> = {t('liabilities.priceI')}</li>
|
||
<li><code>n</code> = {t('liabilities.priceN')}</li>
|
||
</ul>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<div className="alert alert-secondary small mb-0">
|
||
<strong>{t('liabilities.thisContract')}:</strong><br/>
|
||
PV = {formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}<br/>
|
||
i = {formatPercent(selectedAccount.monthly_interest_rate)} {t('liabilities.perMonth')}<br/>
|
||
n = {selectedAccount.total_installments} {t('liabilities.installments').toLowerCase()}<br/>
|
||
<hr className="my-2"/>
|
||
PMT ≈ {formatCurrency(selectedAccount.installments?.[0]?.installment_amount || 0, selectedAccount.currency)}/{t('liabilities.perMonth')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Amortization Behavior */}
|
||
<div className="card mb-3">
|
||
<div className="card-header bg-warning">
|
||
<i className="bi bi-graph-up me-2"></i>
|
||
{t('liabilities.amortizationBehavior')}
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="row g-3">
|
||
<div className="col-md-6">
|
||
<h6><i className="bi bi-1-circle me-2"></i>{t('liabilities.earlyInstallments')}</h6>
|
||
<ul className="small text-muted">
|
||
<li>{t('liabilities.earlyInstallmentsText1')}</li>
|
||
<li>{t('liabilities.earlyInstallmentsText2')}</li>
|
||
</ul>
|
||
</div>
|
||
<div className="col-md-6">
|
||
<h6><i className="bi bi-arrow-right-circle me-2"></i>{t('liabilities.lateInstallments')}</h6>
|
||
<ul className="small text-muted">
|
||
<li>{t('liabilities.lateInstallmentsText1')}</li>
|
||
<li>{t('liabilities.lateInstallmentsText2')}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
{selectedAccount.installments?.length >= 3 && (
|
||
<div className="mt-3">
|
||
<h6>{t('liabilities.visualExample')}:</h6>
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-bordered small">
|
||
<thead className="table-light">
|
||
<tr>
|
||
<th>#</th>
|
||
<th>{t('liabilities.installmentValue')}</th>
|
||
<th>{t('liabilities.interest')}</th>
|
||
<th>{t('liabilities.amortization')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{/* First installment */}
|
||
<tr>
|
||
<td>{selectedAccount.installments[0].installment_number}</td>
|
||
<td>{formatCurrency(selectedAccount.installments[0].installment_amount, selectedAccount.currency)}</td>
|
||
<td className="text-danger">{formatCurrency(selectedAccount.installments[0].interest_amount, selectedAccount.currency)}</td>
|
||
<td className="text-success">{formatCurrency(selectedAccount.installments[0].principal_amount, selectedAccount.currency)}</td>
|
||
</tr>
|
||
{/* Middle installment */}
|
||
{(() => {
|
||
const midIndex = Math.floor(selectedAccount.installments.length / 2);
|
||
const midInst = selectedAccount.installments[midIndex];
|
||
return (
|
||
<tr>
|
||
<td>{midInst.installment_number}</td>
|
||
<td>{formatCurrency(midInst.installment_amount, selectedAccount.currency)}</td>
|
||
<td className="text-danger">{formatCurrency(midInst.interest_amount, selectedAccount.currency)}</td>
|
||
<td className="text-success">{formatCurrency(midInst.principal_amount, selectedAccount.currency)}</td>
|
||
</tr>
|
||
);
|
||
})()}
|
||
{/* Last installment */}
|
||
<tr>
|
||
<td>{selectedAccount.installments[selectedAccount.installments.length - 1].installment_number}</td>
|
||
<td>{formatCurrency(selectedAccount.installments[selectedAccount.installments.length - 1].installment_amount, selectedAccount.currency)}</td>
|
||
<td className="text-danger">{formatCurrency(selectedAccount.installments[selectedAccount.installments.length - 1].interest_amount, selectedAccount.currency)}</td>
|
||
<td className="text-success">{formatCurrency(selectedAccount.installments[selectedAccount.installments.length - 1].principal_amount, selectedAccount.currency)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Interest Rates */}
|
||
<div className="card mb-3">
|
||
<div className="card-header bg-success text-white">
|
||
<i className="bi bi-percent me-2"></i>
|
||
{t('liabilities.interestRates')}
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="row">
|
||
<div className="col-md-4">
|
||
<div className="text-center p-3 border rounded">
|
||
<div className="small text-muted">{t('liabilities.monthlyRate')}</div>
|
||
<div className="h4 text-primary mb-0">{formatPercent(selectedAccount.monthly_interest_rate)}</div>
|
||
<div className="small">{t('liabilities.perMonth')}</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-4">
|
||
<div className="text-center p-3 border rounded">
|
||
<div className="small text-muted">{t('liabilities.annualRate')}</div>
|
||
<div className="h4 text-warning mb-0">{formatPercent(selectedAccount.annual_interest_rate)}</div>
|
||
<div className="small">{t('liabilities.perYear')}</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-md-4">
|
||
<div className="text-center p-3 border rounded">
|
||
<div className="small text-muted">{t('liabilities.totalRate')}</div>
|
||
<div className="h4 text-danger mb-0">{formatPercent(selectedAccount.total_interest_rate)}</div>
|
||
<div className="small">{t('liabilities.interestOverPrincipal')}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Contract Financial Summary */}
|
||
<div className="card mb-3">
|
||
<div className="card-header bg-secondary text-white">
|
||
<i className="bi bi-cash-coin me-2"></i>
|
||
{t('liabilities.contractCost')}
|
||
</div>
|
||
<div className="card-body">
|
||
<p>{t('liabilities.contractCostText')}</p>
|
||
<div className="bg-light p-3 rounded">
|
||
<div className="row text-center">
|
||
<div className="col-md-3">
|
||
<div className="small text-muted">{t('liabilities.principal')}</div>
|
||
<div className="fw-bold">{formatCurrency(selectedAccount.principal_amount, selectedAccount.currency)}</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="small text-muted">{t('liabilities.totalInterest')}</div>
|
||
<div className="fw-bold text-danger">+{formatCurrency(selectedAccount.total_interest, selectedAccount.currency)}</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="small text-muted">{t('liabilities.totalFees')}</div>
|
||
<div className="fw-bold text-warning">+{formatCurrency(selectedAccount.total_fees, selectedAccount.currency)}</div>
|
||
</div>
|
||
<div className="col-md-3">
|
||
<div className="small text-muted">{t('liabilities.totalContract')}</div>
|
||
<div className="fw-bold text-primary">{formatCurrency(selectedAccount.total_contract_value, selectedAccount.currency)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Financial Analysis Summary */}
|
||
<div className="alert alert-success">
|
||
<h6 className="alert-heading">
|
||
<i className="bi bi-lightbulb me-2"></i>
|
||
{t('liabilities.financialSummary')}
|
||
</h6>
|
||
<ul className="mb-0 small">
|
||
<li>{t('liabilities.summaryPointDynamic1', {
|
||
ratio: selectedAccount.principal_amount > 0
|
||
? (selectedAccount.total_interest / selectedAccount.principal_amount * 100).toFixed(0)
|
||
: 0
|
||
})}</li>
|
||
<li>{t('liabilities.summaryPoint2')}</li>
|
||
<li>{t('liabilities.summaryPoint3')}</li>
|
||
<li>{t('liabilities.summaryPoint4')}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div className="modal-footer">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setShowPriceAnalysisModal(false)}
|
||
>
|
||
{t('common.close')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default LiabilityAccounts;
|