webmoney/frontend/src/components/BudgetWizard.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

1313 lines
59 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { budgetService, categoryService, costCenterService, reportService } from '../services/api';
import { useToast } from './Toast';
import { getCurrencyByCode } from '../config/currencies';
import useFormatters from '../hooks/useFormatters';
/**
* BudgetWizard - Wizard avançado para criação de orçamentos
*
* Modos:
* - 'wizard': Modo wizard completo (templates, batch, copiar)
* - 'single': Modo simples para criar/editar um único orçamento (substitui modal antigo)
*
* Features:
* - Multi-step wizard com indicador de progresso
* - Templates predefinidos (básico, familiar, individual)
* - Criação em lote de múltiplos orçamentos
* - Sugestões baseadas em histórico de gastos
* - Seleção de subcategorias
* - Seleção de centro de custos
* - Período flexível (mensal, bimestral, trimestral, semestral, anual)
*/
const BudgetWizard = ({
isOpen,
onClose,
onSuccess,
year,
month,
mode = 'wizard', // 'wizard' ou 'single'
editBudget = null, // Para edição no modo 'single'
}) => {
const { t } = useTranslation();
const toast = useToast();
const { currency } = useFormatters();
// Estados do wizard
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState([]);
const [costCenters, setCostCenters] = useState([]);
const [availableCategories, setAvailableCategories] = useState([]);
const [expenseHistory, setExpenseHistory] = useState({});
const [primaryCurrency, setPrimaryCurrency] = useState('EUR');
// Modo de criação (para wizard)
const [creationMode, setCreationMode] = useState(''); // 'template', 'manual', 'copy'
// Template selecionado
const [selectedTemplate, setSelectedTemplate] = useState('');
// Orçamentos a criar (modo wizard)
const [budgetItems, setBudgetItems] = useState([]);
// Dados do formulário single
const [singleForm, setSingleForm] = useState({
category_id: '',
subcategory_id: '',
cost_center_id: '',
amount: '',
period_type: 'monthly',
is_cumulative: false,
});
// Opções globais (modo wizard)
const [globalOptions, setGlobalOptions] = useState({
period_type: 'monthly',
is_cumulative: false,
});
// Período para copiar
const [copySource, setCopySource] = useState({
year: year || new Date().getFullYear(),
month: (month || new Date().getMonth() + 1) - 1 || 12,
});
// Templates predefinidos
const budgetTemplates = {
basico: {
name: t('budgets.wizard.templates.basic.name') || 'Presupuesto Básico',
description: t('budgets.wizard.templates.basic.desc') || 'Esencial para control mensual',
icon: 'bi-wallet',
color: '#3b82f6',
categories: ['Vivienda', 'Alimentación', 'Transporte', 'Servicios'],
suggested_total: 1500,
},
familiar: {
name: t('budgets.wizard.templates.family.name') || 'Presupuesto Familiar',
description: t('budgets.wizard.templates.family.desc') || 'Completo para familias',
icon: 'bi-house-heart',
color: '#10b981',
categories: ['Vivienda', 'Alimentación', 'Transporte', 'Servicios', 'Salud', 'Educación', 'Ocio', 'Ropa'],
suggested_total: 3000,
},
individual: {
name: t('budgets.wizard.templates.individual.name') || 'Presupuesto Individual',
description: t('budgets.wizard.templates.individual.desc') || 'Para persona soltera',
icon: 'bi-person',
color: '#8b5cf6',
categories: ['Vivienda', 'Alimentación', 'Transporte', 'Ocio', 'Salud'],
suggested_total: 1200,
},
completo: {
name: t('budgets.wizard.templates.complete.name') || 'Presupuesto Completo',
description: t('budgets.wizard.templates.complete.desc') || 'Todas las categorías',
icon: 'bi-clipboard-data',
color: '#f59e0b',
categories: 'all',
suggested_total: 4000,
},
};
// Carregar dados ao abrir
useEffect(() => {
if (isOpen) {
loadData();
resetWizard();
}
}, [isOpen]);
// Preencher form para edição
useEffect(() => {
if (mode === 'single' && editBudget) {
setSingleForm({
category_id: editBudget.category_id || '',
subcategory_id: editBudget.subcategory_id || '',
cost_center_id: editBudget.cost_center_id || '',
amount: editBudget.amount || '',
period_type: editBudget.period_type || 'monthly',
is_cumulative: editBudget.is_cumulative || false,
});
}
}, [mode, editBudget]);
const loadData = async () => {
try {
setLoading(true);
const [categoriesData, costCentersData, availableData] = await Promise.all([
categoryService.getAll(),
costCenterService.getAll(),
budgetService.getAvailableCategories({ year, month }),
]);
const cats = categoriesData?.data || categoriesData;
// Filtrar apenas categorias de gastos (com subcategorias aninhadas)
const expenseCategories = Array.isArray(cats)
? cats.filter(c => c.type === 'expense' || c.type === 'both')
: [];
setCategories(expenseCategories);
// Categorias disponíveis (não usadas ainda)
const available = Array.isArray(availableData) ? availableData : [];
setAvailableCategories(available);
// Centros de custo
const centers = costCentersData?.data || costCentersData;
setCostCenters(Array.isArray(centers) ? centers : []);
// Detectar moeda do usuário
const user = JSON.parse(localStorage.getItem('user') || '{}');
setPrimaryCurrency(user.primary_currency || 'EUR');
// Carregar histórico de gastos (em paralelo)
loadExpenseHistory();
} catch (error) {
console.error('Error loading data:', error);
} finally {
setLoading(false);
}
};
const loadExpenseHistory = async () => {
try {
const lastMonthStart = new Date();
lastMonthStart.setMonth(lastMonthStart.getMonth() - 3);
const data = await reportService.getByCategory({
start_date: lastMonthStart.toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
type: 'expense',
});
const history = {};
if (data && Array.isArray(data)) {
data.forEach(item => {
history[item.category_id] = {
average: item.total / 3,
total: item.total,
category_name: item.category_name,
};
});
}
setExpenseHistory(history);
} catch (error) {
console.error('Error loading expense history:', error);
}
};
const resetWizard = () => {
setStep(1);
setCreationMode('');
setSelectedTemplate('');
setBudgetItems([]);
setGlobalOptions({
period_type: 'monthly',
is_cumulative: false,
});
setSingleForm({
category_id: '',
subcategory_id: '',
cost_center_id: '',
amount: '',
period_type: 'monthly',
is_cumulative: false,
});
};
// ==================== MODO SINGLE ====================
const handleSingleSubmit = async (e) => {
e.preventDefault();
if (!singleForm.category_id || !singleForm.amount) {
toast.warning(t('budgets.wizard.fillRequired') || 'Complete los campos obligatorios');
return;
}
setLoading(true);
try {
const data = {
...singleForm,
year: year || new Date().getFullYear(),
month: month || new Date().getMonth() + 1,
};
if (editBudget) {
await budgetService.update(editBudget.id, data);
toast.success(t('budgets.wizard.updated') || 'Presupuesto actualizado');
} else {
await budgetService.create(data);
toast.success(t('budgets.wizard.created') || 'Presupuesto creado');
}
onSuccess && onSuccess();
onClose();
} catch (error) {
console.error('Error saving budget:', error);
toast.error(error.response?.data?.message || t('common.error'));
} finally {
setLoading(false);
}
};
// Obter subcategorias da categoria selecionada
const getSubcategories = (categoryId) => {
if (!categoryId) return [];
const category = availableCategories.find(c => c.id == categoryId) ||
categories.find(c => c.id == categoryId);
return category?.subcategories || [];
};
// ==================== MODO WIZARD ====================
const applyTemplate = (templateKey) => {
setSelectedTemplate(templateKey);
setCreationMode('template');
const template = budgetTemplates[templateKey];
if (!template) return;
let selectedCategories = [];
if (template.categories === 'all') {
selectedCategories = categories;
} else {
selectedCategories = categories.filter(cat =>
template.categories.includes(cat.name) ||
template.categories.some(tc => cat.name.toLowerCase().includes(tc.toLowerCase()))
);
}
const totalCategories = selectedCategories.length;
const baseAmount = Math.round(template.suggested_total / totalCategories);
const weightMap = {
'Vivienda': 2.5,
'Alimentación': 1.5,
'Transporte': 1.0,
'Servicios': 0.8,
'Salud': 0.5,
'Educación': 0.4,
'Ocio': 0.6,
'Ropa': 0.4,
};
const items = selectedCategories.map(cat => {
const weight = weightMap[cat.name] || 1;
const suggestedAmount = Math.round(baseAmount * weight / 10) * 10;
const historyAmount = expenseHistory[cat.id]?.average || 0;
return {
id: cat.id,
category_id: cat.id,
subcategory_id: null,
cost_center_id: null,
name: cat.name,
icon: cat.icon || 'bi-tag',
color: cat.color || '#6b7280',
subcategories: cat.subcategories || [],
amount: historyAmount > 0 ? Math.round(historyAmount) : suggestedAmount,
suggested: suggestedAmount,
history_avg: Math.round(historyAmount),
selected: true,
};
});
setBudgetItems(items);
setStep(3);
};
const startManualMode = () => {
setCreationMode('manual');
const items = categories.map(cat => ({
id: cat.id,
category_id: cat.id,
subcategory_id: null,
cost_center_id: null,
name: cat.name,
icon: cat.icon || 'bi-tag',
color: cat.color || '#6b7280',
subcategories: cat.subcategories || [],
amount: Math.round(expenseHistory[cat.id]?.average || 0),
suggested: 0,
history_avg: Math.round(expenseHistory[cat.id]?.average || 0),
selected: false,
}));
setBudgetItems(items);
setStep(2);
};
const startCopyMode = async () => {
setCreationMode('copy');
setLoading(true);
try {
const sourceData = await budgetService.getAll({
year: copySource.year,
month: copySource.month,
});
const budgets = sourceData?.data || sourceData || [];
if (budgets.length === 0) {
toast.warning(t('budgets.wizard.noSourceBudgets') || 'No hay presupuestos en este período');
setStep(1);
setLoading(false);
return;
}
const items = budgets.map(b => ({
id: b.id,
category_id: b.category_id,
subcategory_id: b.subcategory_id,
cost_center_id: b.cost_center_id,
name: b.subcategory?.name || b.category?.name || 'Presupuesto',
icon: (b.subcategory || b.category)?.icon || 'bi-tag',
color: (b.subcategory || b.category)?.color || '#6b7280',
subcategories: [],
amount: parseFloat(b.amount),
suggested: parseFloat(b.amount),
history_avg: 0,
selected: true,
}));
setBudgetItems(items);
setStep(3);
} catch (error) {
console.error('Error loading source budgets:', error);
toast.error(t('common.error'));
} finally {
setLoading(false);
}
};
const toggleCategory = (categoryId) => {
setBudgetItems(prev => prev.map(item =>
item.category_id === categoryId
? { ...item, selected: !item.selected }
: item
));
};
const updateItemAmount = (categoryId, amount) => {
setBudgetItems(prev => prev.map(item =>
item.category_id === categoryId
? { ...item, amount: parseFloat(amount) || 0 }
: item
));
};
const updateItemSubcategory = (categoryId, subcategoryId) => {
setBudgetItems(prev => prev.map(item =>
item.category_id === categoryId
? { ...item, subcategory_id: subcategoryId || null }
: item
));
};
const updateItemCostCenter = (categoryId, costCenterId) => {
setBudgetItems(prev => prev.map(item =>
item.category_id === categoryId
? { ...item, cost_center_id: costCenterId || null }
: item
));
};
const selectedItems = budgetItems.filter(item => item.selected);
const totalBudget = selectedItems.reduce((sum, item) => sum + (item.amount || 0), 0);
const handleWizardSubmit = async () => {
if (selectedItems.length === 0) {
toast.warning(t('budgets.wizard.selectCategories') || 'Seleccione al menos una categoría');
return;
}
setLoading(true);
let successCount = 0;
let errorCount = 0;
try {
for (const item of selectedItems) {
try {
await budgetService.create({
category_id: item.category_id,
subcategory_id: item.subcategory_id,
cost_center_id: item.cost_center_id,
amount: item.amount,
year: year || new Date().getFullYear(),
month: month || new Date().getMonth() + 1,
period_type: globalOptions.period_type,
is_cumulative: globalOptions.is_cumulative,
});
successCount++;
} catch (err) {
console.error(`Error creating budget for ${item.name}:`, err);
errorCount++;
}
}
if (successCount > 0) {
toast.success(
t('budgets.wizard.successCount', { count: successCount }) ||
`${successCount} presupuesto(s) creado(s)`
);
onSuccess && onSuccess();
onClose();
}
if (errorCount > 0) {
toast.warning(
t('budgets.wizard.errorCount', { count: errorCount }) ||
`${errorCount} presupuesto(s) ya existían`
);
}
} catch (error) {
console.error('Error creating budgets:', error);
toast.error(t('common.error'));
} finally {
setLoading(false);
}
};
const months = [
{ value: 1, label: t('months.january') || 'Enero' },
{ value: 2, label: t('months.february') || 'Febrero' },
{ value: 3, label: t('months.march') || 'Marzo' },
{ value: 4, label: t('months.april') || 'Abril' },
{ value: 5, label: t('months.may') || 'Mayo' },
{ value: 6, label: t('months.june') || 'Junio' },
{ value: 7, label: t('months.july') || 'Julio' },
{ value: 8, label: t('months.august') || 'Agosto' },
{ value: 9, label: t('months.september') || 'Septiembre' },
{ value: 10, label: t('months.october') || 'Octubre' },
{ value: 11, label: t('months.november') || 'Noviembre' },
{ value: 12, label: t('months.december') || 'Diciembre' },
];
if (!isOpen) return null;
// ==================== RENDER MODO SINGLE ====================
if (mode === 'single') {
const subcategories = getSubcategories(singleForm.category_id);
const selectedCategory = availableCategories.find(c => c.id == singleForm.category_id);
return (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-dialog-centered modal-md">
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
{/* Header elegante como o wizard */}
<div className="modal-header border-0 pb-0">
<div>
<h5 className="modal-title text-white mb-1">
<i className={`bi ${editBudget ? 'bi-pencil-square' : 'bi-plus-circle-dotted'} me-2`}></i>
{editBudget ? t('budgets.editBudget') : t('budgets.newBudget')}
</h5>
<p className="text-slate-400 mb-0 small">
<i className="bi bi-calendar3 me-1"></i>
{months.find(m => m.value === (month || new Date().getMonth() + 1))?.label} {year || new Date().getFullYear()}
</p>
</div>
<button
type="button"
className="btn-close btn-close-white"
onClick={onClose}
></button>
</div>
<form onSubmit={handleSingleSubmit}>
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
{/* Category Selection - Cards bonitos */}
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-tags me-2 text-primary"></i>
{t('budgets.category')} *
</label>
{editBudget ? (
<div className="p-3 rounded d-flex align-items-center" style={{ background: '#0f172a' }}>
<div
className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{ width: 40, height: 40, background: `${editBudget.category?.color || '#6b7280'}20` }}
>
<i className={`bi ${editBudget.category?.icon || 'bi-tag'}`}
style={{ fontSize: '1.2rem', color: editBudget.category?.color || '#6b7280' }}></i>
</div>
<span className="text-white">{editBudget.category?.name}</span>
</div>
) : (
<>
{availableCategories.length === 0 && !loading ? (
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
<i className="bi bi-check-circle text-warning d-block mb-2" style={{ fontSize: '2rem' }}></i>
<span className="text-slate-400">{t('budgets.allCategoriesUsed')}</span>
</div>
) : (
<div className="row g-2">
{availableCategories.map(cat => (
<div key={cat.id} className="col-4 col-md-3">
<div
onClick={() => setSingleForm({...singleForm, category_id: cat.id, subcategory_id: ''})}
className={`card border-0 h-100 ${singleForm.category_id == cat.id ? 'ring-2 ring-primary' : ''}`}
style={{
background: singleForm.category_id == cat.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s',
border: singleForm.category_id == cat.id ? '2px solid #3b82f6' : '2px solid transparent'
}}
>
<div className="card-body p-2 text-center position-relative">
<div
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-1"
style={{ width: 36, height: 36, background: `${cat.color || '#6b7280'}20` }}
>
<i className={`bi ${cat.icon || 'bi-tag'}`} style={{ fontSize: '1rem', color: cat.color || '#6b7280' }}></i>
</div>
<small className="text-white d-block text-truncate" title={cat.name} style={{ fontSize: '0.75rem' }}>
{cat.name}
</small>
{singleForm.category_id == cat.id && (
<i className="bi bi-check-circle-fill text-primary position-absolute"
style={{ top: 4, right: 4, fontSize: '0.8rem' }}></i>
)}
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
{/* Subcategory Selection - Visual melhorado */}
{singleForm.category_id && !editBudget && subcategories.length > 0 && (
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-bookmark me-2 text-info"></i>
{t('budgets.subcategory')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<div className="row g-2">
{/* Opção "Toda la categoría" */}
<div className="col-6 col-md-4">
<div
onClick={() => setSingleForm({...singleForm, subcategory_id: ''})}
className="p-2 rounded text-center"
style={{
background: !singleForm.subcategory_id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
border: !singleForm.subcategory_id ? '2px solid #3b82f6' : '2px solid transparent'
}}
>
<i className={`bi ${selectedCategory?.icon || 'bi-tag'} d-block mb-1`}
style={{ color: selectedCategory?.color || '#6b7280' }}></i>
<small className="text-white">{t('budgets.allCategory')}</small>
</div>
</div>
{subcategories.map(sub => (
<div key={sub.id} className="col-6 col-md-4">
<div
onClick={() => setSingleForm({...singleForm, subcategory_id: sub.id})}
className="p-2 rounded text-center"
style={{
background: singleForm.subcategory_id == sub.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
border: singleForm.subcategory_id == sub.id ? '2px solid #3b82f6' : '2px solid transparent'
}}
>
<i className={`bi ${sub.icon || 'bi-tag'} d-block mb-1`}
style={{ color: sub.color || '#6b7280' }}></i>
<small className="text-white text-truncate d-block" title={sub.name}>{sub.name}</small>
</div>
</div>
))}
</div>
</div>
)}
{/* Cost Center Selection - Visual melhorado */}
{!editBudget && costCenters.length > 0 && (
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-building me-2 text-warning"></i>
{t('budgets.costCenter')}
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
</label>
<div className="row g-2">
<div className="col-6 col-md-4">
<div
onClick={() => setSingleForm({...singleForm, cost_center_id: ''})}
className="p-2 rounded text-center"
style={{
background: !singleForm.cost_center_id ? 'rgba(234, 179, 8, 0.15)' : '#0f172a',
cursor: 'pointer',
border: !singleForm.cost_center_id ? '2px solid #eab308' : '2px solid transparent'
}}
>
<i className="bi bi-dash-circle d-block mb-1 text-slate-400"></i>
<small className="text-white">{t('budgets.noCostCenter') || 'Sin centro'}</small>
</div>
</div>
{costCenters.map(cc => (
<div key={cc.id} className="col-6 col-md-4">
<div
onClick={() => setSingleForm({...singleForm, cost_center_id: cc.id})}
className="p-2 rounded text-center"
style={{
background: singleForm.cost_center_id == cc.id ? 'rgba(234, 179, 8, 0.15)' : '#0f172a',
cursor: 'pointer',
border: singleForm.cost_center_id == cc.id ? '2px solid #eab308' : '2px solid transparent'
}}
>
<i className={`bi ${cc.icon || 'bi-building'} d-block mb-1`}
style={{ color: cc.color || '#eab308' }}></i>
<small className="text-white text-truncate d-block" title={cc.name}>{cc.name}</small>
</div>
</div>
))}
</div>
</div>
)}
{/* Amount - Card destacado */}
<div className="mb-4">
<label className="form-label text-white fw-medium mb-2">
<i className="bi bi-currency-euro me-2 text-success"></i>
{t('budgets.amount')} *
</label>
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
<div className="input-group input-group-lg">
<span className="input-group-text bg-transparent border-0 text-white" style={{ fontSize: '1.5rem' }}>
{getCurrencyByCode(primaryCurrency)?.symbol || '€'}
</span>
<input
type="number"
step="0.01"
min="0.01"
className="form-control bg-transparent border-0 text-white text-center"
style={{ fontSize: '2rem', fontWeight: 'bold' }}
value={singleForm.amount}
onChange={(e) => setSingleForm({...singleForm, amount: e.target.value})}
placeholder="0,00"
required
/>
</div>
</div>
</div>
{/* Period Type & Cumulative - Linha compacta */}
{!editBudget && (
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label text-slate-400 small mb-1">{t('budgets.periodType')}</label>
<select
className="form-select form-select-sm bg-dark border-secondary text-white"
value={singleForm.period_type}
onChange={(e) => setSingleForm({...singleForm, period_type: e.target.value})}
>
<option value="monthly">{t('budgets.monthly')}</option>
<option value="bimestral">{t('budgets.bimestral')}</option>
<option value="trimestral">{t('budgets.trimestral')}</option>
<option value="semestral">{t('budgets.semestral')}</option>
<option value="yearly">{t('budgets.yearly')}</option>
</select>
</div>
<div className="col-md-6 d-flex align-items-end">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
id="isCumulativeSingle"
checked={singleForm.is_cumulative}
onChange={(e) => setSingleForm({...singleForm, is_cumulative: e.target.checked})}
/>
<label className="form-check-label text-slate-400 small" htmlFor="isCumulativeSingle">
{t('budgets.isCumulative')}
</label>
</div>
</div>
</div>
)}
{/* Info - Discreto */}
{!editBudget && (
<div className="small text-slate-500 text-center">
<i className="bi bi-info-circle me-1"></i>
{t('budgets.autoPropagateInfo')}
</div>
)}
</div>
{/* Footer elegante */}
<div className="modal-footer border-0 pt-0">
<button
type="button"
className="btn btn-outline-secondary"
onClick={onClose}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="btn btn-success px-4"
disabled={loading || (!editBudget && (!singleForm.category_id || !singleForm.amount))}
>
{loading ? (
<span className="spinner-border spinner-border-sm me-2"></span>
) : (
<i className="bi bi-check-lg me-1"></i>
)}
{editBudget ? t('common.save') : t('budgets.wizard.createBudget') || 'Crear Presupuesto'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
// ==================== RENDER MODO WIZARD ====================
return (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
<div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
{/* Header */}
<div className="modal-header border-0 pb-0">
<div>
<h5 className="modal-title text-white mb-1">
<i className="bi bi-magic me-2"></i>
{t('budgets.wizard.title') || 'Asistente de Presupuestos'}
</h5>
<p className="text-slate-400 mb-0 small">
{months.find(m => m.value === (month || new Date().getMonth() + 1))?.label} {year || new Date().getFullYear()}
</p>
</div>
<button
type="button"
className="btn-close btn-close-white"
onClick={onClose}
></button>
</div>
{/* Progress Steps */}
<div className="px-4 py-3">
<div className="d-flex justify-content-between align-items-center">
{[1, 2, 3, 4].map((s) => (
<React.Fragment key={s}>
<div
className={`d-flex align-items-center justify-content-center rounded-circle ${
step >= s ? 'bg-primary' : 'bg-slate-600'
}`}
style={{ width: 32, height: 32, transition: 'all 0.3s' }}
>
{step > s ? (
<i className="bi bi-check text-white"></i>
) : (
<span className="text-white small">{s}</span>
)}
</div>
{s < 4 && (
<div
className={`flex-grow-1 mx-2 ${step > s ? 'bg-primary' : 'bg-slate-600'}`}
style={{ height: 2, transition: 'all 0.3s' }}
></div>
)}
</React.Fragment>
))}
</div>
<div className="d-flex justify-content-between mt-1">
<small className={step === 1 ? 'text-primary' : 'text-slate-500'}>
{t('budgets.wizard.step1') || 'Modo'}
</small>
<small className={step === 2 ? 'text-primary' : 'text-slate-500'}>
{t('budgets.wizard.step2') || 'Categorías'}
</small>
<small className={step === 3 ? 'text-primary' : 'text-slate-500'}>
{t('budgets.wizard.step3') || 'Valores'}
</small>
<small className={step === 4 ? 'text-primary' : 'text-slate-500'}>
{t('budgets.wizard.step4') || 'Confirmar'}
</small>
</div>
</div>
{/* Body */}
<div className="modal-body" style={{ maxHeight: '55vh', overflowY: 'auto' }}>
{loading ? (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
) : (
<>
{/* Step 1: Escolher modo */}
{step === 1 && (
<div className="row g-3">
{/* Templates */}
<div className="col-12">
<h6 className="text-slate-400 mb-3">
<i className="bi bi-lightning me-2"></i>
{t('budgets.wizard.quickStart') || 'Inicio Rápido con Plantillas'}
</h6>
<div className="row g-2">
{Object.entries(budgetTemplates).map(([key, template]) => (
<div key={key} className="col-6 col-md-3">
<div
onClick={() => applyTemplate(key)}
className="card border-0 h-100"
style={{
background: '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'}
>
<div className="card-body text-center p-3">
<div
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 48, height: 48, background: `${template.color}20` }}
>
<i className={`bi ${template.icon}`} style={{ fontSize: '1.5rem', color: template.color }}></i>
</div>
<h6 className="text-white mb-1 small">{template.name}</h6>
<small className="text-slate-400 d-block" style={{ fontSize: '0.7rem' }}>
{template.description}
</small>
<small className="text-primary d-block mt-1">
{currency(template.suggested_total, primaryCurrency)}
</small>
</div>
</div>
</div>
))}
</div>
</div>
<div className="col-12">
<hr className="border-secondary my-3" />
</div>
{/* Manual & Copy */}
<div className="col-md-6">
<div
onClick={startManualMode}
className="card border-0 h-100"
style={{ background: '#0f172a', cursor: 'pointer', transition: 'all 0.2s' }}
onMouseEnter={(e) => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseLeave={(e) => e.currentTarget.style.transform = 'translateY(0)'}
>
<div className="card-body p-4">
<div className="d-flex align-items-center mb-3">
<div className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{ width: 48, height: 48, background: 'rgba(59, 130, 246, 0.2)' }}>
<i className="bi bi-pencil-square text-primary" style={{ fontSize: '1.5rem' }}></i>
</div>
<div>
<h6 className="text-white mb-0">{t('budgets.wizard.manual') || 'Crear Manualmente'}</h6>
<small className="text-slate-400">{t('budgets.wizard.manualDesc') || 'Elige categorías y valores'}</small>
</div>
</div>
<div className="text-end">
<i className="bi bi-arrow-right text-primary"></i>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-body p-4">
<div className="d-flex align-items-center mb-3">
<div className="rounded-circle d-flex align-items-center justify-content-center me-3"
style={{ width: 48, height: 48, background: 'rgba(16, 185, 129, 0.2)' }}>
<i className="bi bi-copy text-success" style={{ fontSize: '1.5rem' }}></i>
</div>
<div>
<h6 className="text-white mb-0">{t('budgets.wizard.copy') || 'Copiar de Otro Mes'}</h6>
<small className="text-slate-400">{t('budgets.wizard.copyDesc') || 'Reutiliza presupuestos'}</small>
</div>
</div>
<div className="d-flex gap-2 mb-2">
<select
className="form-select form-select-sm bg-dark border-secondary text-white"
value={copySource.month}
onChange={(e) => setCopySource({ ...copySource, month: 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 bg-dark border-secondary text-white"
value={copySource.year}
onChange={(e) => setCopySource({ ...copySource, year: parseInt(e.target.value) })}
>
{[2024, 2025, 2026].map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<button className="btn btn-outline-success btn-sm w-100" onClick={startCopyMode}>
<i className="bi bi-arrow-right me-1"></i>
{t('budgets.wizard.loadBudgets') || 'Cargar Presupuestos'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Step 2: Selecionar categorias (modo manual) */}
{step === 2 && (
<div>
<div className="d-flex justify-content-between align-items-center mb-3">
<h6 className="text-white mb-0">
<i className="bi bi-tags me-2"></i>
{t('budgets.wizard.selectCategories') || 'Selecciona las Categorías'}
</h6>
<div>
<button
className="btn btn-outline-secondary btn-sm me-2"
onClick={() => setBudgetItems(prev => prev.map(i => ({ ...i, selected: false })))}
>
{t('common.deselectAll') || 'Desmarcar'}
</button>
<button
className="btn btn-outline-primary btn-sm"
onClick={() => setBudgetItems(prev => prev.map(i => ({ ...i, selected: true })))}
>
{t('common.selectAll') || 'Seleccionar Todas'}
</button>
</div>
</div>
<div className="row g-2">
{budgetItems.map(item => (
<div key={item.id} className="col-6 col-md-4 col-lg-3">
<div
onClick={() => toggleCategory(item.category_id)}
className={`card border-0 h-100 ${item.selected ? 'border border-primary' : ''}`}
style={{
background: item.selected ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
<div className="card-body p-3 text-center position-relative">
<div
className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
style={{ width: 40, height: 40, background: `${item.color}20` }}
>
<i className={`bi ${item.icon}`} style={{ fontSize: '1.2rem', color: item.color }}></i>
</div>
<h6 className="text-white mb-1 small text-truncate" title={item.name}>
{item.name}
</h6>
{item.history_avg > 0 && (
<small className="text-slate-400 d-block" style={{ fontSize: '0.7rem' }}>
<i className="bi bi-clock-history me-1"></i>
{currency(item.history_avg, primaryCurrency)}
</small>
)}
{item.selected && (
<i className="bi bi-check-circle-fill text-primary position-absolute"
style={{ top: 8, right: 8 }}></i>
)}
</div>
</div>
</div>
))}
</div>
<div className="mt-3 p-3 rounded" style={{ background: '#0f172a' }}>
<div className="d-flex justify-content-between align-items-center">
<span className="text-slate-400">
{selectedItems.length} {t('budgets.wizard.categoriesSelected') || 'categorías seleccionadas'}
</span>
<div>
<button className="btn btn-outline-secondary me-2" onClick={() => setStep(1)}>
<i className="bi bi-arrow-left me-1"></i>
{t('common.back') || 'Volver'}
</button>
<button
className="btn btn-primary"
onClick={() => setStep(3)}
disabled={selectedItems.length === 0}
>
{t('common.continue') || 'Continuar'}
<i className="bi bi-arrow-right ms-2"></i>
</button>
</div>
</div>
</div>
</div>
)}
{/* Step 3: Definir valores */}
{step === 3 && (
<div>
<h6 className="text-white mb-3">
<i className="bi bi-currency-euro me-2"></i>
{t('budgets.wizard.setAmounts') || 'Define los Valores'}
</h6>
<div className="table-responsive mb-3">
<table className="table table-dark table-hover mb-0" style={{ fontSize: '0.9rem' }}>
<thead>
<tr>
<th>{t('budgets.category') || 'Categoría'}</th>
<th style={{ width: 140 }}>{t('budgets.subcategory') || 'Subcategoría'}</th>
{costCenters.length > 0 && (
<th style={{ width: 130 }}>{t('budgets.costCenter') || 'Centro'}</th>
)}
<th className="text-end" style={{ width: 90 }}>{t('budgets.wizard.history') || 'Hist.'}</th>
<th style={{ width: 120 }}>{t('budgets.amount') || 'Valor'}</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{selectedItems.map(item => (
<tr key={item.id}>
<td>
<div className="d-flex align-items-center">
<i className={`bi ${item.icon} me-2`} style={{ color: item.color }}></i>
<span className="text-white">{item.name}</span>
</div>
</td>
<td>
{item.subcategories && item.subcategories.length > 0 ? (
<select
className="form-select form-select-sm bg-dark border-secondary text-white"
value={item.subcategory_id || ''}
onChange={(e) => updateItemSubcategory(item.category_id, e.target.value)}
>
<option value="">{t('budgets.allCategory') || 'Toda'}</option>
{item.subcategories.map(sub => (
<option key={sub.id} value={sub.id}>{sub.name}</option>
))}
</select>
) : (
<span className="text-slate-500">-</span>
)}
</td>
{costCenters.length > 0 && (
<td>
<select
className="form-select form-select-sm bg-dark border-secondary text-white"
value={item.cost_center_id || ''}
onChange={(e) => updateItemCostCenter(item.category_id, e.target.value)}
>
<option value="">-</option>
{costCenters.map(cc => (
<option key={cc.id} value={cc.id}>{cc.name}</option>
))}
</select>
</td>
)}
<td className="text-end">
{item.history_avg > 0 ? (
<button
className="btn btn-link btn-sm text-info p-0"
onClick={() => updateItemAmount(item.category_id, item.history_avg)}
title={t('budgets.wizard.useHistory') || 'Usar promedio'}
>
{currency(item.history_avg, primaryCurrency)}
</button>
) : (
<span className="text-slate-500">-</span>
)}
</td>
<td>
<div className="input-group input-group-sm">
<span className="input-group-text bg-dark border-secondary text-white">
{getCurrencyByCode(primaryCurrency)?.symbol || '€'}
</span>
<input
type="number"
step="10"
min="0"
className="form-control bg-dark border-secondary text-white"
value={item.amount || ''}
onChange={(e) => updateItemAmount(item.category_id, e.target.value)}
/>
</div>
</td>
<td>
<button
className="btn btn-link btn-sm text-danger p-0"
onClick={() => toggleCategory(item.category_id)}
title={t('common.remove') || 'Eliminar'}
>
<i className="bi bi-x-lg"></i>
</button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-top">
<td colSpan={costCenters.length > 0 ? 4 : 3} className="text-white fw-bold">
{t('budgets.total') || 'Total'}
</td>
<td className="text-white fw-bold">
{currency(totalBudget, primaryCurrency)}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div className="text-end">
<button className="btn btn-outline-secondary me-2" onClick={() => setStep(creationMode === 'manual' ? 2 : 1)}>
<i className="bi bi-arrow-left me-1"></i>
{t('common.back') || 'Volver'}
</button>
<button
className="btn btn-primary"
onClick={() => setStep(4)}
disabled={totalBudget <= 0}
>
{t('common.continue') || 'Continuar'}
<i className="bi bi-arrow-right ms-2"></i>
</button>
</div>
</div>
)}
{/* Step 4: Confirmar */}
{step === 4 && (
<div>
<h6 className="text-white mb-3">
<i className="bi bi-check2-square me-2"></i>
{t('budgets.wizard.confirm') || 'Confirma los Presupuestos'}
</h6>
{/* Summary */}
<div className="card border-0 mb-3" style={{ background: '#0f172a' }}>
<div className="card-body">
<div className="row text-center">
<div className="col-4">
<h3 className="text-primary mb-0">{selectedItems.length}</h3>
<small className="text-slate-400">{t('budgets.wizard.budgets') || 'Presupuestos'}</small>
</div>
<div className="col-4">
<h3 className="text-white mb-0">{currency(totalBudget, primaryCurrency)}</h3>
<small className="text-slate-400">{t('budgets.totalBudgeted') || 'Total'}</small>
</div>
<div className="col-4">
<h3 className="text-success mb-0">
{globalOptions.period_type === 'monthly' ? '12' :
globalOptions.period_type === 'bimestral' ? '6' :
globalOptions.period_type === 'trimestral' ? '4' :
globalOptions.period_type === 'semestral' ? '2' : '1'}
</h3>
<small className="text-slate-400">{t('budgets.wizard.periods') || 'Períodos'}</small>
</div>
</div>
</div>
</div>
{/* Options */}
<div className="row mb-3">
<div className="col-md-6">
<label className="form-label text-slate-400">{t('budgets.periodType') || 'Período'}</label>
<select
className="form-select bg-dark border-secondary text-white"
value={globalOptions.period_type}
onChange={(e) => setGlobalOptions({ ...globalOptions, period_type: e.target.value })}
>
<option value="monthly">{t('budgets.monthly') || 'Mensual'}</option>
<option value="bimestral">{t('budgets.bimestral') || 'Bimestral'}</option>
<option value="trimestral">{t('budgets.trimestral') || 'Trimestral'}</option>
<option value="semestral">{t('budgets.semestral') || 'Semestral'}</option>
<option value="yearly">{t('budgets.yearly') || 'Anual'}</option>
</select>
</div>
<div className="col-md-6 d-flex align-items-end">
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
id="isCumulativeWizard"
checked={globalOptions.is_cumulative}
onChange={(e) => setGlobalOptions({ ...globalOptions, is_cumulative: e.target.checked })}
/>
<label className="form-check-label text-slate-400" htmlFor="isCumulativeWizard">
{t('budgets.isCumulative') || 'Acumulativo'}
</label>
</div>
</div>
</div>
{/* List */}
<div className="mb-3" style={{ maxHeight: '180px', overflowY: 'auto' }}>
{selectedItems.map(item => (
<div key={item.id} className="d-flex justify-content-between align-items-center py-2 border-bottom border-secondary">
<div className="d-flex align-items-center">
<i className={`bi ${item.icon} me-2`} style={{ color: item.color }}></i>
<span className="text-white">{item.name}</span>
{item.subcategory_id && (
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>
{item.subcategories?.find(s => s.id == item.subcategory_id)?.name}
</span>
)}
</div>
<span className="text-primary fw-bold">{currency(item.amount, primaryCurrency)}</span>
</div>
))}
</div>
<div className="alert alert-info py-2">
<small>
<i className="bi bi-info-circle me-1"></i>
{t('budgets.autoPropagateInfo') || 'Los presupuestos se propagarán automáticamente'}
</small>
</div>
<div className="text-end">
<button className="btn btn-outline-secondary me-2" onClick={() => setStep(3)}>
<i className="bi bi-arrow-left me-1"></i>
{t('common.back') || 'Volver'}
</button>
<button className="btn btn-success" onClick={handleWizardSubmit} disabled={loading}>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
{t('common.creating') || 'Creando...'}
</>
) : (
<>
<i className="bi bi-check-lg me-1"></i>
{t('budgets.wizard.createBudgets') || 'Crear Presupuestos'}
</>
)}
</button>
</div>
</div>
)}
</>
)}
</div>
{/* Footer */}
{step === 1 && (
<div className="modal-footer border-0 pt-0">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
{t('common.cancel') || 'Cancelar'}
</button>
</div>
)}
</div>
</div>
</div>
);
};
export default BudgetWizard;