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,140 @@
'use client'
import { FixedDebt, VariableDebt } from '@/lib/types'
import { formatCurrency, formatShortDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { Pencil, Trash2, Check } from 'lucide-react'
interface DebtCardProps {
debt: FixedDebt | VariableDebt
type: 'fixed' | 'variable'
onTogglePaid: () => void
onEdit: () => void
onDelete: () => void
}
const fixedCategoryColors: Record<string, string> = {
housing: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
services: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
subscription: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
other: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
}
const variableCategoryColors: Record<string, string> = {
shopping: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
food: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
entertainment: 'bg-indigo-500/20 text-indigo-400 border-indigo-500/30',
health: 'bg-red-500/20 text-red-400 border-red-500/30',
transport: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
other: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
}
const categoryLabels: Record<string, string> = {
housing: 'Vivienda',
services: 'Servicios',
subscription: 'Suscripción',
shopping: 'Compras',
food: 'Comida',
entertainment: 'Entretenimiento',
health: 'Salud',
transport: 'Transporte',
other: 'Otro',
}
export function DebtCard({ debt, type, onTogglePaid, onEdit, onDelete }: DebtCardProps) {
const isFixed = type === 'fixed'
const categoryColors = isFixed ? fixedCategoryColors : variableCategoryColors
const categoryColor = categoryColors[debt.category] || categoryColors.other
const getDueInfo = () => {
if (isFixed) {
const fixedDebt = debt as FixedDebt
return `Vence día ${fixedDebt.dueDay}`
} else {
const variableDebt = debt as VariableDebt
return formatShortDate(variableDebt.date)
}
}
return (
<div
className={cn(
'group relative bg-slate-800 border border-slate-700/50 rounded-lg p-4',
'transition-all duration-200 hover:border-slate-600',
debt.isPaid && 'opacity-60'
)}
>
<div className="flex items-start gap-3">
{/* Checkbox */}
<button
onClick={onTogglePaid}
className={cn(
'mt-1 w-5 h-5 rounded border-2 flex items-center justify-center',
'transition-colors duration-200',
debt.isPaid
? 'bg-emerald-500 border-emerald-500'
: 'border-slate-500 hover:border-emerald-400'
)}
aria-label={debt.isPaid ? 'Marcar como no pagada' : 'Marcar como pagada'}
>
{debt.isPaid && <Check className="w-3 h-3 text-white" />}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h3
className={cn(
'text-white font-medium truncate',
debt.isPaid && 'line-through text-slate-400'
)}
>
{debt.name}
</h3>
<p className="text-slate-400 text-sm mt-0.5">{getDueInfo()}</p>
</div>
<span className="font-mono text-emerald-400 font-semibold whitespace-nowrap">
{formatCurrency(debt.amount)}
</span>
</div>
<div className="flex items-center justify-between mt-3">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border',
categoryColor
)}
>
{categoryLabels[debt.category] || debt.category}
</span>
{isFixed && (debt as FixedDebt).isAutoDebit && (
<span className="text-xs text-slate-500">
Débito automático
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onEdit}
className="p-1.5 text-slate-400 hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
aria-label="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={onDelete}
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
aria-label="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,224 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { FixedDebt, VariableDebt } from '@/lib/types'
import { DebtCard } from './DebtCard'
import { FixedDebtForm } from './FixedDebtForm'
import { VariableDebtForm } from './VariableDebtForm'
import { Plus, Wallet } from 'lucide-react'
import { cn, formatCurrency, calculateTotalFixedDebts, calculateTotalVariableDebts } from '@/lib/utils'
type DebtType = 'fixed' | 'variable'
export function DebtSection() {
const [activeTab, setActiveTab] = useState<DebtType>('fixed')
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingDebt, setEditingDebt] = useState<FixedDebt | VariableDebt | null>(null)
const {
fixedDebts,
variableDebts,
addFixedDebt,
updateFixedDebt,
deleteFixedDebt,
toggleFixedDebtPaid,
addVariableDebt,
updateVariableDebt,
deleteVariableDebt,
toggleVariableDebtPaid,
} = useFinanzasStore()
const currentDebts = activeTab === 'fixed' ? fixedDebts : variableDebts
const totalUnpaid = activeTab === 'fixed'
? calculateTotalFixedDebts(fixedDebts)
: calculateTotalVariableDebts(variableDebts)
const handleAdd = () => {
setEditingDebt(null)
setIsModalOpen(true)
}
const handleEdit = (debt: FixedDebt | VariableDebt) => {
setEditingDebt(debt)
setIsModalOpen(true)
}
const handleCloseModal = () => {
setIsModalOpen(false)
setEditingDebt(null)
}
const handleSubmitFixed = (data: Omit<FixedDebt, 'id' | 'isPaid'>) => {
if (editingDebt?.id) {
updateFixedDebt(editingDebt.id, data)
} else {
addFixedDebt({ ...data, isPaid: false })
}
handleCloseModal()
}
const handleSubmitVariable = (data: Omit<VariableDebt, 'id' | 'isPaid'>) => {
if (editingDebt?.id) {
updateVariableDebt(editingDebt.id, data)
} else {
addVariableDebt({ ...data, isPaid: false })
}
handleCloseModal()
}
const handleDelete = (debt: FixedDebt | VariableDebt) => {
if (confirm('¿Estás seguro de que deseas eliminar esta deuda?')) {
if (activeTab === 'fixed') {
deleteFixedDebt(debt.id)
} else {
deleteVariableDebt(debt.id)
}
}
}
const handleTogglePaid = (debt: FixedDebt | VariableDebt) => {
if (activeTab === 'fixed') {
toggleFixedDebtPaid(debt.id)
} else {
toggleVariableDebtPaid(debt.id)
}
}
const paidCount = currentDebts.filter(d => d.isPaid).length
const unpaidCount = currentDebts.filter(d => !d.isPaid).length
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">Deudas</h1>
<p className="text-slate-400 text-sm mt-1">
Gestiona tus gastos fijos y variables
</p>
</div>
<button
onClick={handleAdd}
className={cn(
'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" />
Agregar
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<p className="text-slate-400 text-sm">Total pendiente</p>
<p className="text-xl font-mono font-semibold text-emerald-400 mt-1">
{formatCurrency(totalUnpaid)}
</p>
</div>
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<p className="text-slate-400 text-sm">Pagadas</p>
<p className="text-xl font-semibold text-blue-400 mt-1">
{paidCount}
</p>
</div>
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<p className="text-slate-400 text-sm">Pendientes</p>
<p className="text-xl font-semibold text-orange-400 mt-1">
{unpaidCount}
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('fixed')}
className={cn(
'px-4 py-2 rounded-lg font-medium transition-colors',
activeTab === 'fixed'
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'
)}
>
Fijas ({fixedDebts.length})
</button>
<button
onClick={() => setActiveTab('variable')}
className={cn(
'px-4 py-2 rounded-lg font-medium transition-colors',
activeTab === 'variable'
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'
)}
>
Variables ({variableDebts.length})
</button>
</div>
{/* Debt List */}
<div className="space-y-3">
{currentDebts.length === 0 ? (
<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 deudas {activeTab === 'fixed' ? 'fijas' : 'variables'}
</h3>
<p className="text-slate-500 mt-2">
Haz clic en "Agregar" para crear una nueva deuda
</p>
</div>
) : (
currentDebts.map((debt) => (
<DebtCard
key={debt.id}
debt={debt}
type={activeTab}
onTogglePaid={() => handleTogglePaid(debt)}
onEdit={() => handleEdit(debt)}
onDelete={() => handleDelete(debt)}
/>
))
)}
</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">
{editingDebt
? 'Editar deuda'
: activeTab === 'fixed'
? 'Nueva deuda fija'
: 'Nueva deuda variable'}
</h2>
{activeTab === 'fixed' ? (
<FixedDebtForm
initialData={editingDebt as Partial<FixedDebt> | undefined}
onSubmit={handleSubmitFixed}
onCancel={handleCloseModal}
/>
) : (
<VariableDebtForm
initialData={editingDebt as Partial<VariableDebt> | undefined}
onSubmit={handleSubmitVariable}
onCancel={handleCloseModal}
/>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,212 @@
'use client'
import { useState } from 'react'
import { FixedDebt } from '@/lib/types'
import { cn } from '@/lib/utils'
interface FixedDebtFormProps {
initialData?: Partial<FixedDebt>
onSubmit: (data: Omit<FixedDebt, 'id' | 'isPaid'>) => void
onCancel: () => void
}
const categories = [
{ value: 'housing', label: 'Vivienda' },
{ value: 'services', label: 'Servicios' },
{ value: 'subscription', label: 'Suscripción' },
{ value: 'other', label: 'Otro' },
] as const
export function FixedDebtForm({ initialData, onSubmit, onCancel }: FixedDebtFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
amount: initialData?.amount || 0,
dueDay: initialData?.dueDay || 1,
category: initialData?.category || 'other',
isAutoDebit: initialData?.isAutoDebit || false,
notes: initialData?.notes || '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido'
}
if (formData.amount <= 0) {
newErrors.amount = 'El monto debe ser mayor a 0'
}
if (formData.dueDay < 1 || formData.dueDay > 31) {
newErrors.dueDay = 'El día debe estar entre 1 y 31'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
onSubmit(formData)
}
}
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>
<label htmlFor="name" className="block text-sm font-medium text-slate-300 mb-1">
Nombre <span className="text-red-400">*</span>
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
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.name ? 'border-red-500' : 'border-slate-600'
)}
placeholder="Ej: Alquiler, Internet, etc."
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="amount" className="block text-sm font-medium text-slate-300 mb-1">
Monto <span className="text-red-400">*</span>
</label>
<input
type="number"
id="amount"
min="0"
step="0.01"
value={formData.amount || ''}
onChange={(e) => updateField('amount', 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.amount ? 'border-red-500' : 'border-slate-600'
)}
placeholder="0.00"
/>
{errors.amount && <p className="mt-1 text-sm text-red-400">{errors.amount}</p>}
</div>
<div>
<label htmlFor="dueDay" className="block text-sm font-medium text-slate-300 mb-1">
Día de vencimiento <span className="text-red-400">*</span>
</label>
<input
type="number"
id="dueDay"
min="1"
max="31"
value={formData.dueDay}
onChange={(e) => updateField('dueDay', parseInt(e.target.value) || 1)}
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.dueDay ? 'border-red-500' : 'border-slate-600'
)}
placeholder="1"
/>
{errors.dueDay && <p className="mt-1 text-sm text-red-400">{errors.dueDay}</p>}
</div>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium text-slate-300 mb-1">
Categoría
</label>
<select
id="category"
value={formData.category}
onChange={(e) => updateField('category', e.target.value as FixedDebt['category'])}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500'
)}
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isAutoDebit"
checked={formData.isAutoDebit}
onChange={(e) => updateField('isAutoDebit', e.target.checked)}
className="w-4 h-4 rounded border-slate-600 bg-slate-800 text-blue-500 focus:ring-blue-500/50"
/>
<label htmlFor="isAutoDebit" className="text-sm text-slate-300">
Tiene débito automático
</label>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-slate-300 mb-1">
Notas <span className="text-slate-500">(opcional)</span>
</label>
<textarea
id="notes"
rows={3}
value={formData.notes}
onChange={(e) => updateField('notes', e.target.value)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
'resize-none'
)}
placeholder="Notas adicionales..."
/>
</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?.id ? 'Guardar cambios' : 'Agregar deuda'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import { VariableDebt } from '@/lib/types'
import { cn } from '@/lib/utils'
interface VariableDebtFormProps {
initialData?: Partial<VariableDebt>
onSubmit: (data: Omit<VariableDebt, 'id' | 'isPaid'>) => void
onCancel: () => void
}
const categories = [
{ value: 'shopping', label: 'Compras' },
{ value: 'food', label: 'Comida' },
{ value: 'entertainment', label: 'Entretenimiento' },
{ value: 'health', label: 'Salud' },
{ value: 'transport', label: 'Transporte' },
{ value: 'other', label: 'Otro' },
] as const
export function VariableDebtForm({ initialData, onSubmit, onCancel }: VariableDebtFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
amount: initialData?.amount || 0,
date: initialData?.date || new Date().toISOString().split('T')[0],
category: initialData?.category || 'other',
notes: initialData?.notes || '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido'
}
if (formData.amount <= 0) {
newErrors.amount = 'El monto debe ser mayor a 0'
}
if (!formData.date) {
newErrors.date = 'La fecha es requerida'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
onSubmit(formData)
}
}
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>
<label htmlFor="name" className="block text-sm font-medium text-slate-300 mb-1">
Nombre <span className="text-red-400">*</span>
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
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.name ? 'border-red-500' : 'border-slate-600'
)}
placeholder="Ej: Supermercado, Cena, etc."
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="amount" className="block text-sm font-medium text-slate-300 mb-1">
Monto <span className="text-red-400">*</span>
</label>
<input
type="number"
id="amount"
min="0"
step="0.01"
value={formData.amount || ''}
onChange={(e) => updateField('amount', 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.amount ? 'border-red-500' : 'border-slate-600'
)}
placeholder="0.00"
/>
{errors.amount && <p className="mt-1 text-sm text-red-400">{errors.amount}</p>}
</div>
<div>
<label htmlFor="date" className="block text-sm font-medium text-slate-300 mb-1">
Fecha <span className="text-red-400">*</span>
</label>
<input
type="date"
id="date"
value={formData.date}
onChange={(e) => updateField('date', 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.date ? 'border-red-500' : 'border-slate-600'
)}
/>
{errors.date && <p className="mt-1 text-sm text-red-400">{errors.date}</p>}
</div>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium text-slate-300 mb-1">
Categoría
</label>
<select
id="category"
value={formData.category}
onChange={(e) => updateField('category', e.target.value as VariableDebt['category'])}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500'
)}
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-slate-300 mb-1">
Notas <span className="text-slate-500">(opcional)</span>
</label>
<textarea
id="notes"
rows={3}
value={formData.notes}
onChange={(e) => updateField('notes', e.target.value)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
'resize-none'
)}
placeholder="Notas adicionales..."
/>
</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?.id ? 'Guardar cambios' : 'Agregar deuda'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,4 @@
export { DebtCard } from './DebtCard';
export { DebtSection } from './DebtSection';
export { FixedDebtForm } from './FixedDebtForm';
export { VariableDebtForm } from './VariableDebtForm';