- Redesigned category create/edit modal with elegant wizard-style UI - Redesigned batch categorization modal with visual cards and better preview - Added missing i18n translations (common.continue, creating, remove) - Added budgets.general and wizard translations for ES, PT-BR, EN - Fixed 3 demo user transactions that were missing categories
589 lines
23 KiB
JavaScript
589 lines
23 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { budgetService, categoryService, costCenterService } from '../services/api';
|
|
import useFormatters from '../hooks/useFormatters';
|
|
import { getCurrencyByCode } from '../config/currencies';
|
|
import ConfirmModal from '../components/ConfirmModal';
|
|
import BudgetWizard from '../components/BudgetWizard';
|
|
|
|
const Budgets = () => {
|
|
const { t } = useTranslation();
|
|
const { currency } = useFormatters();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [budgets, setBudgets] = useState([]);
|
|
const [categories, setCategories] = useState([]);
|
|
const [availableCategories, setAvailableCategories] = useState([]);
|
|
const [costCenters, setCostCenters] = useState([]);
|
|
const [year, setYear] = useState(new Date().getFullYear());
|
|
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingBudget, setEditingBudget] = useState(null);
|
|
const [deleteBudget, setDeleteBudget] = useState(null);
|
|
const [yearSummary, setYearSummary] = useState(null);
|
|
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
|
|
const [showWizard, setShowWizard] = useState(false);
|
|
|
|
// Meses con i18n
|
|
const getMonths = () => [
|
|
{ value: 1, label: t('months.january') },
|
|
{ value: 2, label: t('months.february') },
|
|
{ value: 3, label: t('months.march') },
|
|
{ value: 4, label: t('months.april') },
|
|
{ value: 5, label: t('months.may') },
|
|
{ value: 6, label: t('months.june') },
|
|
{ value: 7, label: t('months.july') },
|
|
{ value: 8, label: t('months.august') },
|
|
{ value: 9, label: t('months.september') },
|
|
{ value: 10, label: t('months.october') },
|
|
{ value: 11, label: t('months.november') },
|
|
{ value: 12, label: t('months.december') },
|
|
];
|
|
|
|
const months = getMonths();
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [year, month]);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [budgetsData, categoriesData, availableData, summaryData, costCentersData] = await Promise.all([
|
|
budgetService.getAll({ year, month }),
|
|
categoryService.getAll(),
|
|
budgetService.getAvailableCategories({ year, month }),
|
|
budgetService.getYearSummary({ year }),
|
|
costCenterService.getAll(),
|
|
]);
|
|
|
|
// Extraer datos del response si viene en formato { data, ... }
|
|
const budgetsList = budgetsData?.data || budgetsData;
|
|
setBudgets(Array.isArray(budgetsList) ? budgetsList : []);
|
|
|
|
// Detectar moneda primaria de los presupuestos o usar EUR
|
|
if (budgetsList?.length > 0 && budgetsList[0].currency) {
|
|
setPrimaryCurrency(budgetsList[0].currency);
|
|
}
|
|
|
|
const cats = categoriesData?.data || categoriesData;
|
|
// Filtrar categorías de gastos: expense o both
|
|
setCategories(Array.isArray(cats) ? cats.filter(c => c.type === 'expense' || c.type === 'both') : []);
|
|
|
|
// Categorías disponibles (no usadas aún)
|
|
const available = Array.isArray(availableData) ? availableData : [];
|
|
setAvailableCategories(available);
|
|
|
|
setYearSummary(Array.isArray(summaryData) ? summaryData : []);
|
|
|
|
// Cost Centers
|
|
const centers = costCentersData?.data || costCentersData;
|
|
setCostCenters(Array.isArray(centers) ? centers : []);
|
|
} catch (error) {
|
|
console.error('Error loading budgets:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteBudget) return;
|
|
try {
|
|
await budgetService.delete(deleteBudget.id);
|
|
setDeleteBudget(null);
|
|
loadData();
|
|
} catch (error) {
|
|
console.error('Error deleting budget:', error);
|
|
}
|
|
};
|
|
|
|
const handleEdit = (budget) => {
|
|
setEditingBudget(budget);
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleCopyToNextMonth = async () => {
|
|
try {
|
|
await budgetService.copyToNextMonth(year, month);
|
|
// Move to next month view
|
|
if (month === 12) {
|
|
setYear(year + 1);
|
|
setMonth(1);
|
|
} else {
|
|
setMonth(month + 1);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error copying budgets:', error);
|
|
}
|
|
};
|
|
|
|
const openNewBudget = () => {
|
|
setEditingBudget(null);
|
|
setShowModal(true);
|
|
};
|
|
|
|
const getProgressColor = (percentage) => {
|
|
if (percentage >= 100) return '#ef4444';
|
|
if (percentage >= 80) return '#f59e0b';
|
|
if (percentage >= 60) return '#eab308';
|
|
return '#10b981';
|
|
};
|
|
|
|
// Calculate totals agrupados por moneda
|
|
const safeBudgets = Array.isArray(budgets) ? budgets : [];
|
|
|
|
// Agrupar por moneda
|
|
const totalsByCurrency = safeBudgets.reduce((acc, b) => {
|
|
const curr = b.currency || primaryCurrency;
|
|
if (!acc[curr]) {
|
|
acc[curr] = { budgeted: 0, spent: 0 };
|
|
}
|
|
acc[curr].budgeted += parseFloat(b.amount || 0);
|
|
acc[curr].spent += parseFloat(b.spent_amount || 0);
|
|
return acc;
|
|
}, {});
|
|
|
|
// Totales principales (para compatibilidad)
|
|
const totals = {
|
|
budgeted: safeBudgets.reduce((sum, b) => sum + parseFloat(b.amount || 0), 0),
|
|
spent: safeBudgets.reduce((sum, b) => sum + parseFloat(b.spent_amount || 0), 0),
|
|
};
|
|
totals.remaining = totals.budgeted - totals.spent;
|
|
totals.percentage = totals.budgeted > 0 ? (totals.spent / totals.budgeted) * 100 : 0;
|
|
|
|
// Formatear totales por moneda
|
|
const formatTotalsByCurrency = (type) => {
|
|
const entries = Object.entries(totalsByCurrency);
|
|
if (entries.length === 0) return currency(0, primaryCurrency);
|
|
if (entries.length === 1) {
|
|
const [curr, vals] = entries[0];
|
|
const value = type === 'budgeted' ? vals.budgeted :
|
|
type === 'spent' ? vals.spent :
|
|
vals.budgeted - vals.spent;
|
|
return currency(value, curr);
|
|
}
|
|
return entries.map(([curr, vals]) => {
|
|
const value = type === 'budgeted' ? vals.budgeted :
|
|
type === 'spent' ? vals.spent :
|
|
vals.budgeted - vals.spent;
|
|
return currency(value, curr);
|
|
}).join(' + ');
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
|
<div className="spinner-border text-primary" role="status">
|
|
<span className="visually-hidden">{t('common.loading')}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="budgets-container">
|
|
{/* Header */}
|
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h4 className="text-white mb-1 fw-bold">
|
|
<i className="bi bi-wallet2 me-2"></i>
|
|
{t('budgets.title')}
|
|
</h4>
|
|
<p className="text-slate-400 mb-0 small">{t('budgets.subtitle')}</p>
|
|
</div>
|
|
<div className="d-flex gap-2">
|
|
<button
|
|
className="btn btn-outline-secondary btn-sm"
|
|
onClick={handleCopyToNextMonth}
|
|
title="Copiar presupuestos al próximo mes"
|
|
>
|
|
<i className="bi bi-copy me-1"></i>
|
|
{t('budgets.copyToNext')}
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={() => setShowWizard(true)}
|
|
title={t('budgets.wizard.title') || 'Assistente de Orçamentos'}
|
|
>
|
|
<i className="bi bi-magic me-1"></i>
|
|
{t('budgets.wizard.button') || 'Assistente'}
|
|
</button>
|
|
<button className="btn btn-primary" onClick={openNewBudget}>
|
|
<i className="bi bi-plus-lg me-1"></i>
|
|
{t('budgets.addBudget')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Month/Year Selector */}
|
|
<div className="card border-0 mb-4" style={{ background: '#0f172a' }}>
|
|
<div className="card-body py-2">
|
|
<div className="row align-items-center">
|
|
<div className="col-auto">
|
|
<button
|
|
className="btn btn-outline-secondary btn-sm"
|
|
onClick={() => {
|
|
if (month === 1) {
|
|
setYear(year - 1);
|
|
setMonth(12);
|
|
} else {
|
|
setMonth(month - 1);
|
|
}
|
|
}}
|
|
>
|
|
<i className="bi bi-chevron-left"></i>
|
|
</button>
|
|
</div>
|
|
<div className="col text-center">
|
|
<select
|
|
className="form-select form-select-sm d-inline-block w-auto bg-dark border-secondary text-white me-2"
|
|
value={month}
|
|
onChange={(e) => setMonth(parseInt(e.target.value))}
|
|
>
|
|
{months.map(m => (
|
|
<option key={m.value} value={m.value}>{m.label}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="form-select form-select-sm d-inline-block w-auto bg-dark border-secondary text-white"
|
|
value={year}
|
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
|
>
|
|
{[2024, 2025, 2026].map(y => (
|
|
<option key={y} value={y}>{y}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-auto">
|
|
<button
|
|
className="btn btn-outline-secondary btn-sm"
|
|
onClick={() => {
|
|
if (month === 12) {
|
|
setYear(year + 1);
|
|
setMonth(1);
|
|
} else {
|
|
setMonth(month + 1);
|
|
}
|
|
}}
|
|
>
|
|
<i className="bi bi-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="row g-3 mb-4">
|
|
<div className="col-md-3">
|
|
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' }}>
|
|
<div className="card-body text-white py-3">
|
|
<small className="opacity-75">{t('budgets.totalBudgeted')}</small>
|
|
<h5 className="mb-0">{formatTotalsByCurrency('budgeted')}</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }}>
|
|
<div className="card-body text-white py-3">
|
|
<small className="opacity-75">{t('budgets.totalSpent')}</small>
|
|
<h5 className="mb-0">{formatTotalsByCurrency('spent')}</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div
|
|
className="card border-0"
|
|
style={{
|
|
background: totals.remaining >= 0
|
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
|
: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
|
}}
|
|
>
|
|
<div className="card-body text-white py-3">
|
|
<small className="opacity-75">{t('budgets.remaining')}</small>
|
|
<h5 className="mb-0">{formatTotalsByCurrency('remaining')}</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400">{t('budgets.usage')}</small>
|
|
<h4 className="mb-1" style={{ color: getProgressColor(totals.percentage) }}>
|
|
{totals.percentage.toFixed(1)}%
|
|
</h4>
|
|
<div className="progress bg-slate-700" style={{ height: '4px' }}>
|
|
<div
|
|
className="progress-bar"
|
|
style={{
|
|
width: `${Math.min(totals.percentage, 100)}%`,
|
|
background: getProgressColor(totals.percentage)
|
|
}}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Budgets List */}
|
|
{budgets.length === 0 ? (
|
|
<div className="card border-0 text-center py-5" style={{ background: '#0f172a' }}>
|
|
<div className="card-body">
|
|
<i className="bi bi-wallet2 text-slate-500" style={{ fontSize: '4rem' }}></i>
|
|
<h5 className="text-white mt-3">{t('budgets.noBudgets')}</h5>
|
|
<p className="text-slate-400">{t('budgets.noBudgetsDescription')}</p>
|
|
<button className="btn btn-primary" onClick={openNewBudget}>
|
|
<i className="bi bi-plus-lg me-1"></i>
|
|
{t('budgets.createFirst')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="row g-3">
|
|
{budgets.map(budget => {
|
|
const spent = parseFloat(budget.spent_amount || 0);
|
|
const amount = parseFloat(budget.amount);
|
|
const percentage = budget.usage_percentage || ((spent / amount) * 100);
|
|
const remaining = budget.remaining_amount || (amount - spent);
|
|
const isExceeded = spent > amount;
|
|
|
|
return (
|
|
<div key={budget.id} className="col-md-6 col-lg-4">
|
|
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
|
<div className="card-body">
|
|
{/* Header */}
|
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
|
<div className="d-flex align-items-center">
|
|
<div
|
|
className="rounded-circle p-2 me-2"
|
|
style={{ background: `${(budget.subcategory || budget.category)?.color || '#3b82f6'}20` }}
|
|
>
|
|
<i
|
|
className={`bi ${(budget.subcategory || budget.category)?.icon || 'bi-tag'}`}
|
|
style={{
|
|
color: (budget.subcategory || budget.category)?.color || '#3b82f6',
|
|
fontSize: '1.25rem'
|
|
}}
|
|
></i>
|
|
</div>
|
|
<div>
|
|
<h6 className="text-white mb-0">
|
|
{budget.subcategory ? budget.subcategory.name : budget.category?.name || t('budgets.general')}
|
|
</h6>
|
|
{budget.subcategory && budget.category && (
|
|
<small className="text-slate-400">
|
|
<i className="bi bi-arrow-return-right me-1"></i>
|
|
{budget.category.name}
|
|
</small>
|
|
)}
|
|
{budget.cost_center && (
|
|
<small className="text-info">
|
|
<i className="bi bi-building me-1"></i>
|
|
{budget.cost_center.name}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="dropdown">
|
|
<button
|
|
className="btn btn-link text-slate-400 p-0"
|
|
data-bs-toggle="dropdown"
|
|
>
|
|
<i className="bi bi-three-dots-vertical"></i>
|
|
</button>
|
|
<ul className="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
|
<li>
|
|
<button
|
|
className="dropdown-item"
|
|
onClick={() => handleEdit(budget)}
|
|
>
|
|
<i className="bi bi-pencil me-2"></i>
|
|
{t('common.edit')}
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button
|
|
className="dropdown-item text-danger"
|
|
onClick={() => setDeleteBudget(budget)}
|
|
>
|
|
<i className="bi bi-trash me-2"></i>
|
|
{t('common.delete')}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
<div className="mb-3">
|
|
<div className="d-flex justify-content-between mb-1">
|
|
<span className="text-slate-400 small">{t('budgets.spent')}</span>
|
|
<span className="text-slate-400 small">{t('budgets.budgeted')}</span>
|
|
</div>
|
|
<div className="d-flex justify-content-between mb-2">
|
|
<span className={`fw-bold ${isExceeded ? 'text-danger' : 'text-white'}`}>
|
|
{currency(spent, budget.currency || primaryCurrency)}
|
|
</span>
|
|
<span className="text-white">{currency(amount, budget.currency || primaryCurrency)}</span>
|
|
</div>
|
|
<div className="progress bg-slate-700" style={{ height: '8px' }}>
|
|
<div
|
|
className="progress-bar"
|
|
style={{
|
|
width: `${Math.min(percentage, 100)}%`,
|
|
background: getProgressColor(percentage)
|
|
}}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="d-flex justify-content-between">
|
|
<div>
|
|
<small className="text-slate-400 d-block">{t('budgets.remaining')}</small>
|
|
<span className={`fw-bold ${remaining >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
{currency(remaining, budget.currency || primaryCurrency)}
|
|
</span>
|
|
</div>
|
|
<div className="text-end">
|
|
<small className="text-slate-400 d-block">{t('budgets.usage')}</small>
|
|
<span
|
|
className="fw-bold"
|
|
style={{ color: getProgressColor(percentage) }}
|
|
>
|
|
{percentage.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Warning */}
|
|
{isExceeded && (
|
|
<div className="alert alert-danger py-2 mt-3 mb-0">
|
|
<small>
|
|
<i className="bi bi-exclamation-triangle me-1"></i>
|
|
{t('budgets.exceeded')} {currency(Math.abs(remaining), budget.currency || primaryCurrency)}
|
|
</small>
|
|
</div>
|
|
)}
|
|
{!isExceeded && percentage >= 80 && (
|
|
<div className="alert alert-warning py-2 mt-3 mb-0">
|
|
<small>
|
|
<i className="bi bi-exclamation-circle me-1"></i>
|
|
{t('budgets.almostExceeded')}
|
|
</small>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Year Summary */}
|
|
{yearSummary && yearSummary.length > 0 && (
|
|
<div className="mt-4">
|
|
<h5 className="text-white mb-3">
|
|
<i className="bi bi-calendar3 me-2"></i>
|
|
{t('budgets.yearSummary')} {year}
|
|
</h5>
|
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
|
<div className="card-body p-0">
|
|
<div className="table-responsive">
|
|
<table className="table table-dark table-hover mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>{t('budgets.month')}</th>
|
|
<th className="text-end">{t('budgets.budgeted')}</th>
|
|
<th className="text-end">{t('budgets.spent')}</th>
|
|
<th className="text-end">{t('budgets.remaining')}</th>
|
|
<th className="text-end">{t('budgets.usage')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{yearSummary.map(item => {
|
|
const monthName = months.find(m => m.value === item.month)?.label || item.month;
|
|
const isCurrentMonth = item.month === new Date().getMonth() + 1 && year === new Date().getFullYear();
|
|
|
|
return (
|
|
<tr
|
|
key={item.month}
|
|
className={isCurrentMonth ? 'table-primary' : ''}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => setMonth(item.month)}
|
|
>
|
|
<td>
|
|
{monthName}
|
|
{isCurrentMonth && (
|
|
<span className="badge bg-primary ms-2">{t('budgets.currentMonth')}</span>
|
|
)}
|
|
</td>
|
|
<td className="text-end">{currency(item.budgeted, primaryCurrency)}</td>
|
|
<td className="text-end text-danger">{currency(item.spent, primaryCurrency)}</td>
|
|
<td className={`text-end ${item.remaining >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
{currency(item.remaining, primaryCurrency)}
|
|
</td>
|
|
<td className="text-end">
|
|
<span
|
|
className="badge"
|
|
style={{
|
|
background: getProgressColor(item.percentage),
|
|
minWidth: '60px'
|
|
}}
|
|
>
|
|
{item.percentage.toFixed(1)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Budget Form Modal - Using BudgetWizard with mode='single' */}
|
|
<BudgetWizard
|
|
isOpen={showModal}
|
|
onClose={() => {
|
|
setShowModal(false);
|
|
setEditingBudget(null);
|
|
}}
|
|
onSuccess={loadData}
|
|
year={year}
|
|
month={month}
|
|
mode="single"
|
|
editBudget={editingBudget}
|
|
/>
|
|
|
|
{/* Delete Confirmation */}
|
|
<ConfirmModal
|
|
show={!!deleteBudget}
|
|
onClose={() => setDeleteBudget(null)}
|
|
onConfirm={handleDelete}
|
|
title={t('budgets.deleteBudget')}
|
|
message={t('budgets.deleteConfirm', { category: deleteBudget?.category?.name })}
|
|
confirmText={t('common.delete')}
|
|
variant="danger"
|
|
/>
|
|
|
|
{/* Budget Wizard */}
|
|
<BudgetWizard
|
|
isOpen={showWizard}
|
|
onClose={() => setShowWizard(false)}
|
|
onSuccess={loadData}
|
|
year={year}
|
|
month={month}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Budgets;
|