webmoney/frontend/src/pages/Budgets.jsx
marcoitaloesp-ai 9c9d6443e7
v1.57.0: Redesign category modals + i18n updates + demo transactions fix
- 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
2025-12-18 19:06:07 +00:00

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;