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,63 @@
'use client'
import { RefreshCw } from 'lucide-react'
import { getMonthName } from '@/lib/utils'
import { cn } from '@/lib/utils'
interface DashboardHeaderProps {
onRefresh?: () => void
isRefreshing?: boolean
}
export function DashboardHeader({
onRefresh,
isRefreshing = false,
}: DashboardHeaderProps) {
const now = new Date()
const currentMonth = now.getMonth() + 1
const currentYear = now.getFullYear()
const monthName = getMonthName(currentMonth)
// Formatear fecha actual
const formattedDate = new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(now)
return (
<div className="flex flex-col gap-4 border-b border-slate-700 bg-slate-800/50 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white">
Dashboard
<span className="ml-2 text-lg font-normal text-slate-400">
{monthName} {currentYear}
</span>
</h1>
<p className="mt-1 text-sm capitalize text-slate-400">
{formattedDate}
</p>
</div>
{onRefresh && (
<button
onClick={onRefresh}
disabled={isRefreshing}
className={cn(
'inline-flex items-center gap-2 rounded-lg border border-slate-600',
'bg-slate-700 px-4 py-2 text-sm font-medium text-white',
'transition-colors hover:bg-slate-600',
'focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-slate-800',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<RefreshCw
className={cn('h-4 w-4', isRefreshing && 'animate-spin')}
/>
{isRefreshing ? 'Actualizando...' : 'Actualizar'}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { FixedDebt, VariableDebt } from '@/lib/types'
import { formatCurrency } from '@/lib/utils'
interface ExpenseChartProps {
fixedDebts: FixedDebt[]
variableDebts: VariableDebt[]
}
// Colores por categoría
const CATEGORY_COLORS: Record<string, string> = {
// Deudas fijas
housing: '#10b981', // emerald-500
services: '#3b82f6', // blue-500
subscription: '#8b5cf6', // violet-500
other: '#64748b', // slate-500
// Deudas variables
shopping: '#f59e0b', // amber-500
food: '#ef4444', // red-500
entertainment: '#ec4899', // pink-500
health: '#06b6d4', // cyan-500
transport: '#84cc16', // lime-500
}
// Nombres de categorías en español
const CATEGORY_NAMES: Record<string, string> = {
housing: 'Vivienda',
services: 'Servicios',
subscription: 'Suscripciones',
other: 'Otros',
shopping: 'Compras',
food: 'Comida',
entertainment: 'Entretenimiento',
health: 'Salud',
transport: 'Transporte',
}
interface ChartData {
name: string
value: number
color: string
category: string
}
export function ExpenseChart({ fixedDebts, variableDebts }: ExpenseChartProps) {
// Agrupar gastos por categoría
const categoryTotals = new Map<string, number>()
// Agregar deudas fijas no pagadas
fixedDebts
.filter((debt) => !debt.isPaid)
.forEach((debt) => {
const current = categoryTotals.get(debt.category) || 0
categoryTotals.set(debt.category, current + debt.amount)
})
// Agregar deudas variables no pagadas
variableDebts
.filter((debt) => !debt.isPaid)
.forEach((debt) => {
const current = categoryTotals.get(debt.category) || 0
categoryTotals.set(debt.category, current + debt.amount)
})
// Convertir a formato de datos para el gráfico
const data: ChartData[] = Array.from(categoryTotals.entries())
.map(([category, value]) => ({
name: CATEGORY_NAMES[category] || category,
value,
color: CATEGORY_COLORS[category] || '#64748b',
category,
}))
.filter((item) => item.value > 0)
.sort((a, b) => b.value - a.value)
// Calcular total
const total = data.reduce((sum, item) => sum + item.value, 0)
if (data.length === 0) {
return (
<div className="flex h-64 items-center justify-center rounded-xl border border-slate-700 bg-slate-800">
<p className="text-slate-500">No hay gastos pendientes</p>
</div>
)
}
return (
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Distribución de Gastos
</h3>
<div className="flex flex-col gap-6 lg:flex-row">
{/* Gráfico de dona */}
<div className="h-64 w-full lg:w-1/2">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value) =>
typeof value === 'number' ? formatCurrency(value) : value
}
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
color: '#fff',
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Leyenda */}
<div className="flex w-full flex-col justify-center gap-3 lg:w-1/2">
{data.map((item) => {
const percentage = total > 0 ? (item.value / total) * 100 : 0
return (
<div key={item.category} className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: item.color }}
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-300">
{item.name}
</span>
<span className="text-sm text-slate-400">
{percentage.toFixed(1)}%
</span>
</div>
<p className="text-xs text-slate-500">
{formatCurrency(item.value)}
</p>
</div>
</div>
)
})}
{/* Total */}
<div className="mt-4 border-t border-slate-700 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-400">Total</span>
<span className="font-mono text-lg font-bold text-emerald-400">
{formatCurrency(total)}
</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { LucideIcon, TrendingUp, TrendingDown } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'
interface MetricCardProps {
title: string
amount: number
subtitle?: string
trend?: {
value: number
isPositive: boolean
}
icon: LucideIcon
color?: string
}
export function MetricCard({
title,
amount,
subtitle,
trend,
icon: Icon,
color = 'text-emerald-400',
}: MetricCardProps) {
return (
<div className="relative overflow-hidden rounded-xl border border-slate-700 bg-slate-800 p-6 shadow-lg">
{/* Icono en esquina superior derecha */}
<div className={cn('absolute right-4 top-4', color)}>
<Icon className="h-10 w-10 opacity-80" />
</div>
{/* Contenido */}
<div className="relative">
{/* Título */}
<h3 className="text-sm font-medium text-slate-400">{title}</h3>
{/* Monto */}
<p className="mt-2 font-mono text-3xl font-bold text-emerald-400">
{formatCurrency(amount)}
</p>
{/* Subtítulo */}
{subtitle && (
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
)}
{/* Indicador de tendencia */}
{trend && (
<div className="mt-3 flex items-center gap-1.5">
{trend.isPositive ? (
<>
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium text-emerald-500">
+{trend.value}%
</span>
</>
) : (
<>
<TrendingDown className="h-4 w-4 text-rose-500" />
<span className="text-sm font-medium text-rose-500">
-{trend.value}%
</span>
</>
)}
<span className="text-sm text-slate-500">vs mes anterior</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import { Plus, CreditCard, Wallet } from 'lucide-react'
interface QuickActionsProps {
onAddDebt: () => void
onAddCard: () => void
onAddPayment: () => void
}
interface ActionButton {
label: string
icon: React.ElementType
onClick: () => void
color: string
}
export function QuickActions({
onAddDebt,
onAddCard,
onAddPayment,
}: QuickActionsProps) {
const actions: ActionButton[] = [
{
label: 'Agregar Deuda',
icon: Plus,
onClick: onAddDebt,
color: 'bg-emerald-500 hover:bg-emerald-600',
},
{
label: 'Nueva Tarjeta',
icon: CreditCard,
onClick: onAddCard,
color: 'bg-blue-500 hover:bg-blue-600',
},
{
label: 'Registrar Pago',
icon: Wallet,
onClick: onAddPayment,
color: 'bg-violet-500 hover:bg-violet-600',
},
]
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{actions.map((action) => {
const Icon = action.icon
return (
<button
key={action.label}
onClick={action.onClick}
className={`
group flex flex-col items-center gap-3 rounded-xl p-6
transition-all duration-200 ease-out
${action.color}
focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-slate-800
`}
>
<div className="rounded-full bg-white/20 p-4 transition-transform group-hover:scale-110">
<Icon className="h-8 w-8 text-white" />
</div>
<span className="font-medium text-white">{action.label}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import { ArrowDownLeft, ArrowUpRight, CreditCard, Wallet } from 'lucide-react'
import { useFinanzasStore } from '@/lib/store'
import { formatCurrency, formatShortDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
interface RecentActivityProps {
limit?: number
}
interface ActivityItem {
id: string
type: 'fixed_debt' | 'variable_debt' | 'card_payment'
title: string
amount: number
date: string
description?: string
}
export function RecentActivity({ limit = 5 }: RecentActivityProps) {
const { fixedDebts, variableDebts, cardPayments, creditCards } =
useFinanzasStore()
// Combinar todas las actividades
const activities: ActivityItem[] = [
// Deudas fijas recientes
...fixedDebts.slice(0, limit).map((debt) => ({
id: debt.id,
type: 'fixed_debt' as const,
title: debt.name,
amount: debt.amount,
date: new Date().toISOString(), // Usar fecha actual ya que fixedDebt no tiene fecha de creación
description: `Vence el día ${debt.dueDay}`,
})),
// Deudas variables recientes
...variableDebts.slice(0, limit).map((debt) => ({
id: debt.id,
type: 'variable_debt' as const,
title: debt.name,
amount: debt.amount,
date: debt.date,
description: debt.notes,
})),
// Pagos de tarjetas recientes
...cardPayments.slice(0, limit).map((payment) => {
const card = creditCards.find((c) => c.id === payment.cardId)
return {
id: payment.id,
type: 'card_payment' as const,
title: `Pago - ${card?.name || 'Tarjeta'}`,
amount: payment.amount,
date: payment.date,
description: payment.description,
}
}),
]
// Ordenar por fecha (más recientes primero)
const sortedActivities = activities
.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
)
.slice(0, limit)
// Configuración por tipo de actividad
const activityConfig = {
fixed_debt: {
icon: Wallet,
label: 'Deuda Fija',
color: 'text-amber-400',
bgColor: 'bg-amber-400/10',
},
variable_debt: {
icon: ArrowUpRight,
label: 'Gasto',
color: 'text-rose-400',
bgColor: 'bg-rose-400/10',
},
card_payment: {
icon: CreditCard,
label: 'Pago Tarjeta',
color: 'text-blue-400',
bgColor: 'bg-blue-400/10',
},
}
if (sortedActivities.length === 0) {
return (
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Actividad Reciente
</h3>
<div className="flex h-32 items-center justify-center">
<p className="text-slate-500">No hay actividad reciente</p>
</div>
</div>
)
}
return (
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Actividad Reciente
</h3>
<div className="space-y-3">
{sortedActivities.map((activity) => {
const config = activityConfig[activity.type]
const Icon = config.icon
return (
<div
key={activity.id}
className="flex items-center gap-4 rounded-lg border border-slate-700/50 bg-slate-700/30 p-4 transition-colors hover:bg-slate-700/50"
>
{/* Icono */}
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full',
config.bgColor
)}
>
<Icon className={cn('h-5 w-5', config.color)} />
</div>
{/* Contenido */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="truncate font-medium text-white">
{activity.title}
</h4>
<span
className={cn(
'shrink-0 font-mono font-medium',
activity.type === 'card_payment'
? 'text-emerald-400'
: 'text-rose-400'
)}
>
{activity.type === 'card_payment' ? '+' : '-'}
{formatCurrency(activity.amount)}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-sm text-slate-400">
<span className={cn('text-xs', config.color)}>
{config.label}
</span>
<span></span>
<span>{formatShortDate(activity.date)}</span>
{activity.description && (
<>
<span></span>
<span className="truncate">{activity.description}</span>
</>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import { AlertCircle, CreditCard, PiggyBank, Wallet } from 'lucide-react'
import { MetricCard } from './MetricCard'
import { ExpenseChart } from './ExpenseChart'
import { useFinanzasStore } from '@/lib/store'
import {
calculateTotalFixedDebts,
calculateTotalVariableDebts,
} from '@/lib/utils'
import {
getCurrentMonthBudget,
calculateCurrentSpending,
} from '@/lib/alerts'
import { cn } from '@/lib/utils'
export function SummarySection() {
const {
fixedDebts,
variableDebts,
creditCards,
monthlyBudgets,
alerts,
currentMonth,
currentYear,
} = useFinanzasStore()
// Calcular métricas
const totalFixedDebts = calculateTotalFixedDebts(fixedDebts)
const totalVariableDebts = calculateTotalVariableDebts(variableDebts)
const totalPendingDebts = totalFixedDebts + totalVariableDebts
const totalCardBalance = creditCards.reduce(
(sum, card) => sum + card.currentBalance,
0
)
const currentBudget = getCurrentMonthBudget(
monthlyBudgets,
currentMonth,
currentYear
)
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
// Presupuesto disponible (ingresos - gastos actuales)
const availableBudget = currentBudget
? currentBudget.totalIncome - currentSpending
: 0
// Meta de ahorro proyectada
const projectedSavings = currentBudget
? currentBudget.totalIncome - currentSpending
: 0
const savingsGoal = currentBudget?.savingsGoal || 0
// Alertas no leídas (primeras 3)
const unreadAlerts = alerts
.filter((alert) => !alert.isRead)
.slice(0, 3)
// Colores por severidad de alerta
const severityColors = {
danger: 'border-rose-500 bg-rose-500/10 text-rose-400',
warning: 'border-amber-500 bg-amber-500/10 text-amber-400',
info: 'border-blue-500 bg-blue-500/10 text-blue-400',
}
return (
<div className="space-y-6">
{/* Grid de métricas */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
title="Deudas Pendientes"
amount={totalPendingDebts}
subtitle={`${fixedDebts.filter((d) => !d.isPaid).length + variableDebts.filter((d) => !d.isPaid).length} pagos pendientes`}
icon={Wallet}
color="text-rose-400"
/>
<MetricCard
title="Balance en Tarjetas"
amount={totalCardBalance}
subtitle={`${creditCards.length} tarjetas activas`}
icon={CreditCard}
color="text-blue-400"
/>
<MetricCard
title="Presupuesto Disponible"
amount={availableBudget}
subtitle={
currentBudget
? `de ${currentBudget.totalIncome.toLocaleString('es-AR', {
style: 'currency',
currency: 'ARS',
})} ingresos`
: 'Sin presupuesto definido'
}
icon={PiggyBank}
color="text-emerald-400"
/>
<MetricCard
title="Meta de Ahorro"
amount={projectedSavings}
subtitle={
savingsGoal > 0
? `${((projectedSavings / savingsGoal) * 100).toFixed(0)}% de la meta`
: 'Sin meta definida'
}
icon={PiggyBank}
color="text-violet-400"
/>
</div>
{/* Gráfico y alertas */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Gráfico de distribución */}
<ExpenseChart fixedDebts={fixedDebts} variableDebts={variableDebts} />
{/* Alertas destacadas */}
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Alertas Destacadas
</h3>
{unreadAlerts.length === 0 ? (
<div className="flex h-48 items-center justify-center">
<p className="text-slate-500">No hay alertas pendientes</p>
</div>
) : (
<div className="space-y-3">
{unreadAlerts.map((alert) => (
<div
key={alert.id}
className={cn(
'flex items-start gap-3 rounded-lg border p-4',
severityColors[alert.severity]
)}
>
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
<div>
<h4 className="font-medium">{alert.title}</h4>
<p className="mt-1 text-sm opacity-90">{alert.message}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { MetricCard } from './MetricCard'
export { DashboardHeader } from './DashboardHeader'
export { ExpenseChart } from './ExpenseChart'
export { QuickActions } from './QuickActions'
export { SummarySection } from './SummarySection'
export { RecentActivity } from './RecentActivity'