feat: initial commit - finanzas app

Complete personal finance management application with:
- Dashboard with financial metrics and alerts
- Credit card management and payments
- Fixed and variable debt tracking
- Monthly budget planning
- Intelligent alert system
- Responsive design with Tailwind CSS

Tech stack: Next.js 14, TypeScript, Zustand, Recharts

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
renato97
2026-01-29 00:00:32 +00:00
commit 712b06f118
65 changed files with 8556 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
'use client'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
interface BudgetCardProps {
label: string
amount: number
trend?: 'up' | 'down' | 'neutral'
color?: string
}
export function BudgetCard({ label, amount, trend = 'neutral', color }: BudgetCardProps) {
const getTrendIcon = () => {
switch (trend) {
case 'up':
return <TrendingUp className="w-4 h-4 text-emerald-400" />
case 'down':
return <TrendingDown className="w-4 h-4 text-red-400" />
default:
return <Minus className="w-4 h-4 text-slate-500" />
}
}
const getTrendText = () => {
switch (trend) {
case 'up':
return <span className="text-emerald-400 text-xs">Positivo</span>
case 'down':
return <span className="text-red-400 text-xs">Negativo</span>
default:
return <span className="text-slate-500 text-xs">Neutral</span>
}
}
const textColor = color || 'text-white'
return (
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<div className="flex items-center justify-between">
<p className="text-slate-400 text-sm">{label}</p>
{getTrendIcon()}
</div>
<p className={cn('text-2xl font-mono font-semibold mt-2', textColor)}>
{formatCurrency(amount)}
</p>
<div className="mt-2">{getTrendText()}</div>
</div>
)
}

View File

@@ -0,0 +1,198 @@
'use client'
import { useState } from 'react'
import { MonthlyBudget } from '@/lib/types'
import { cn, getMonthName } from '@/lib/utils'
interface BudgetFormProps {
onSubmit: (budget: MonthlyBudget) => void
onCancel: () => void
initialData?: MonthlyBudget
}
const months = Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: getMonthName(i + 1),
}))
export function BudgetForm({ onSubmit, onCancel, initialData }: BudgetFormProps) {
const now = new Date()
const [formData, setFormData] = useState({
totalIncome: initialData?.totalIncome || 0,
savingsGoal: initialData?.savingsGoal || 0,
month: initialData?.month || now.getMonth() + 1,
year: initialData?.year || now.getFullYear(),
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (formData.totalIncome <= 0) {
newErrors.totalIncome = 'Los ingresos deben ser mayores a 0'
}
if (formData.savingsGoal >= formData.totalIncome) {
newErrors.savingsGoal = 'La meta de ahorro debe ser menor que los ingresos'
}
if (formData.month < 1 || formData.month > 12) {
newErrors.month = 'El mes debe estar entre 1 y 12'
}
if (formData.year < 2000 || formData.year > 2100) {
newErrors.year = 'El año no es válido'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
onSubmit({
month: formData.month,
year: formData.year,
totalIncome: formData.totalIncome,
savingsGoal: formData.savingsGoal,
fixedExpenses: initialData?.fixedExpenses || 0,
variableExpenses: initialData?.variableExpenses || 0,
})
}
}
const updateField = <K extends keyof typeof formData>(
field: K,
value: typeof formData[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="month" className="block text-sm font-medium text-slate-300 mb-1">
Mes <span className="text-red-400">*</span>
</label>
<select
id="month"
value={formData.month}
onChange={(e) => updateField('month', parseInt(e.target.value))}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.month ? 'border-red-500' : 'border-slate-600'
)}
>
{months.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
{errors.month && <p className="mt-1 text-sm text-red-400">{errors.month}</p>}
</div>
<div>
<label htmlFor="year" className="block text-sm font-medium text-slate-300 mb-1">
Año <span className="text-red-400">*</span>
</label>
<input
type="number"
id="year"
min="2000"
max="2100"
value={formData.year}
onChange={(e) => updateField('year', parseInt(e.target.value) || now.getFullYear())}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.year ? 'border-red-500' : 'border-slate-600'
)}
/>
{errors.year && <p className="mt-1 text-sm text-red-400">{errors.year}</p>}
</div>
</div>
<div>
<label htmlFor="totalIncome" className="block text-sm font-medium text-slate-300 mb-1">
Ingresos totales <span className="text-red-400">*</span>
</label>
<input
type="number"
id="totalIncome"
min="0"
step="0.01"
value={formData.totalIncome || ''}
onChange={(e) => updateField('totalIncome', parseFloat(e.target.value) || 0)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.totalIncome ? 'border-red-500' : 'border-slate-600'
)}
placeholder="0.00"
/>
{errors.totalIncome && <p className="mt-1 text-sm text-red-400">{errors.totalIncome}</p>}
</div>
<div>
<label htmlFor="savingsGoal" className="block text-sm font-medium text-slate-300 mb-1">
Meta de ahorro <span className="text-red-400">*</span>
</label>
<input
type="number"
id="savingsGoal"
min="0"
step="0.01"
value={formData.savingsGoal || ''}
onChange={(e) => updateField('savingsGoal', parseFloat(e.target.value) || 0)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.savingsGoal ? 'border-red-500' : 'border-slate-600'
)}
placeholder="0.00"
/>
{errors.savingsGoal && <p className="mt-1 text-sm text-red-400">{errors.savingsGoal}</p>}
{formData.totalIncome > 0 && (
<p className="mt-1 text-sm text-slate-500">
Disponible para gastos: {((formData.totalIncome - formData.savingsGoal) / formData.totalIncome * 100).toFixed(0)}%
</p>
)}
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className={cn(
'flex-1 px-4 py-2 bg-slate-700 text-slate-200 rounded-lg font-medium',
'hover:bg-slate-600 transition-colors'
)}
>
Cancelar
</button>
<button
type="submit"
className={cn(
'flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
'hover:bg-blue-500 transition-colors'
)}
>
{initialData ? 'Guardar cambios' : 'Crear presupuesto'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import { cn, formatCurrency } from '@/lib/utils'
interface BudgetProgressProps {
current: number
max: number
label: string
color?: string
}
export function BudgetProgress({ current, max, label, color }: BudgetProgressProps) {
const percentage = max > 0 ? Math.min((current / max) * 100, 100) : 0
const getColorClass = () => {
if (color) return color
if (percentage < 70) return 'bg-emerald-500'
if (percentage < 90) return 'bg-amber-500'
return 'bg-red-500'
}
return (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-300">{label}</span>
<span className="text-sm text-slate-400">
{formatCurrency(current)} <span className="text-slate-600">/ {formatCurrency(max)}</span>
</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500 ease-out', getColorClass())}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="flex justify-between mt-1">
<span className="text-xs text-slate-500">{percentage.toFixed(0)}% usado</span>
{percentage >= 100 && (
<span className="text-xs text-red-400 font-medium">Límite alcanzado</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { cn, formatCurrency } from '@/lib/utils'
interface BudgetRingProps {
spent: number
total: number
label: string
}
export function BudgetRing({ spent, total, label }: BudgetRingProps) {
const percentage = total > 0 ? Math.min((spent / total) * 100, 100) : 0
const remaining = Math.max(total - spent, 0)
const getColor = () => {
if (percentage < 70) return { stroke: '#10b981', bg: 'text-emerald-400' }
if (percentage < 90) return { stroke: '#f59e0b', bg: 'text-amber-400' }
return { stroke: '#ef4444', bg: 'text-red-400' }
}
const colors = getColor()
const radius = 80
const strokeWidth = 12
const normalizedRadius = radius - strokeWidth / 2
const circumference = normalizedRadius * 2 * Math.PI
const strokeDashoffset = circumference - (percentage / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative">
<svg
width={radius * 2}
height={radius * 2}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
stroke="#334155"
strokeWidth={strokeWidth}
fill="transparent"
r={normalizedRadius}
cx={radius}
cy={radius}
/>
{/* Progress circle */}
<circle
stroke={colors.stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="transparent"
r={normalizedRadius}
cx={radius}
cy={radius}
style={{
strokeDasharray: `${circumference} ${circumference}`,
strokeDashoffset,
transition: 'stroke-dashoffset 0.5s ease-in-out',
}}
/>
</svg>
{/* Center content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={cn('text-3xl font-bold', colors.bg)}>
{percentage.toFixed(0)}%
</span>
<span className="text-slate-400 text-sm mt-1">usado</span>
</div>
</div>
{/* Stats below */}
<div className="mt-4 text-center">
<p className="text-slate-400 text-sm">{label}</p>
<p className="text-lg font-semibold text-white mt-1">
{formatCurrency(spent)} <span className="text-slate-500">/ {formatCurrency(total)}</span>
</p>
<p className="text-sm text-slate-500 mt-1">
{formatCurrency(remaining)} disponible
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
import { useState, useMemo } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { MonthlyBudget } from '@/lib/types'
import { BudgetForm } from './BudgetForm'
import { BudgetRing } from './BudgetRing'
import { BudgetProgress } from './BudgetProgress'
import { BudgetCard } from './BudgetCard'
import { cn, formatCurrency, getMonthName, calculateTotalFixedDebts, calculateTotalVariableDebts, calculateCardPayments } from '@/lib/utils'
import { Plus, Wallet, Edit3, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react'
export function BudgetSection() {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const {
monthlyBudgets,
fixedDebts,
variableDebts,
cardPayments,
currentMonth,
currentYear,
setMonthlyBudget,
} = useFinanzasStore()
const currentBudget = useMemo(() => {
return monthlyBudgets.find(
(b) => b.month === currentMonth && b.year === currentYear
)
}, [monthlyBudgets, currentMonth, currentYear])
const fixedExpenses = useMemo(() => calculateTotalFixedDebts(fixedDebts), [fixedDebts])
const variableExpenses = useMemo(() => calculateTotalVariableDebts(variableDebts), [variableDebts])
const cardExpenses = useMemo(() => calculateCardPayments(cardPayments), [cardPayments])
const totalSpent = fixedExpenses + variableExpenses + cardExpenses
const totalIncome = currentBudget?.totalIncome || 0
const savingsGoal = currentBudget?.savingsGoal || 0
const availableForExpenses = totalIncome - savingsGoal
const remaining = availableForExpenses - totalSpent
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate()
const currentDay = new Date().getDate()
const daysRemaining = daysInMonth - currentDay
const dailySpendRate = currentDay > 0 ? totalSpent / currentDay : 0
const projectedEndOfMonth = totalSpent + dailySpendRate * daysRemaining
const handleCreateBudget = () => {
setIsEditing(false)
setIsModalOpen(true)
}
const handleEditBudget = () => {
setIsEditing(true)
setIsModalOpen(true)
}
const handleCloseModal = () => {
setIsModalOpen(false)
setIsEditing(false)
}
const handleSubmit = (budget: MonthlyBudget) => {
setMonthlyBudget(budget)
handleCloseModal()
}
if (!currentBudget) {
return (
<div className="bg-slate-900 min-h-screen p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Presupuesto Mensual</h1>
<p className="text-slate-400 text-sm mt-1">
{getMonthName(currentMonth)} {currentYear}
</p>
</div>
</div>
<div className="text-center py-16 bg-slate-800/50 border border-slate-700/50 rounded-lg">
<Wallet className="w-12 h-12 text-slate-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-300">
No hay presupuesto para este mes
</h3>
<p className="text-slate-500 mt-2 mb-6">
Crea un presupuesto para comenzar a gestionar tus finanzas
</p>
<button
onClick={handleCreateBudget}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
'hover:bg-blue-500 transition-colors'
)}
>
<Plus className="w-4 h-4" />
Crear presupuesto
</button>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleCloseModal}
/>
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-bold text-white mb-4">
Nuevo presupuesto
</h2>
<BudgetForm
onSubmit={handleSubmit}
onCancel={handleCloseModal}
/>
</div>
</div>
</div>
)}
</div>
</div>
)
}
return (
<div className="bg-slate-900 min-h-screen p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Presupuesto Mensual</h1>
<p className="text-slate-400 text-sm mt-1">
{getMonthName(currentMonth)} {currentYear}
</p>
</div>
<button
onClick={handleEditBudget}
className={cn(
'flex items-center gap-2 px-4 py-2 bg-slate-700 text-white rounded-lg font-medium',
'hover:bg-slate-600 transition-colors'
)}
>
<Edit3 className="w-4 h-4" />
Editar
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<BudgetCard
label="Ingresos totales"
amount={totalIncome}
trend="up"
color="text-emerald-400"
/>
<BudgetCard
label="Meta de ahorro"
amount={savingsGoal}
trend="neutral"
color="text-blue-400"
/>
<BudgetCard
label="Gastado"
amount={totalSpent}
trend={totalSpent > availableForExpenses ? 'down' : 'neutral'}
color={totalSpent > availableForExpenses ? 'text-red-400' : 'text-amber-400'}
/>
<BudgetCard
label="Disponible"
amount={remaining}
trend={remaining > 0 ? 'up' : 'down'}
color={remaining > 0 ? 'text-emerald-400' : 'text-red-400'}
/>
</div>
{/* Budget Ring and Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Ring */}
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6 flex items-center justify-center">
<BudgetRing
spent={totalSpent}
total={availableForExpenses}
label="Presupuesto mensual"
/>
</div>
{/* Breakdown */}
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-white mb-4">Desglose de gastos</h3>
<div className="space-y-4">
<BudgetProgress
current={fixedExpenses}
max={availableForExpenses}
label="Deudas fijas pendientes"
/>
<BudgetProgress
current={variableExpenses}
max={availableForExpenses}
label="Deudas variables pendientes"
/>
<BudgetProgress
current={cardExpenses}
max={availableForExpenses}
label="Pagos de tarjetas"
/>
</div>
</div>
</div>
{/* Projection */}
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
projectedEndOfMonth > availableForExpenses ? 'bg-red-500/10' : 'bg-emerald-500/10'
)}>
{projectedEndOfMonth > availableForExpenses ? (
<AlertCircle className="w-5 h-5 text-red-400" />
) : (
<TrendingUp className="w-5 h-5 text-emerald-400" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-white">Proyección</h3>
<p className="text-slate-400 mt-1">
A tu ritmo actual de gasto ({formatCurrency(dailySpendRate)}/día),
{projectedEndOfMonth > availableForExpenses ? (
<span className="text-red-400">
{' '}terminarás el mes con un déficit de {formatCurrency(projectedEndOfMonth - availableForExpenses)}.
</span>
) : (
<span className="text-emerald-400">
{' '}terminarás el mes con un superávit de {formatCurrency(availableForExpenses - projectedEndOfMonth)}.
</span>
)}
</p>
<p className="text-slate-500 text-sm mt-2">
Quedan {daysRemaining} días en el mes
</p>
</div>
</div>
</div>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleCloseModal}
/>
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-bold text-white mb-4">
{isEditing ? 'Editar presupuesto' : 'Nuevo presupuesto'}
</h2>
<BudgetForm
onSubmit={handleSubmit}
onCancel={handleCloseModal}
initialData={isEditing ? currentBudget : undefined}
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,5 @@
export { BudgetForm } from './BudgetForm';
export { BudgetRing } from './BudgetRing';
export { BudgetProgress } from './BudgetProgress';
export { BudgetCard } from './BudgetCard';
export { BudgetSection } from './BudgetSection';