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,32 @@
'use client'
import { cn } from '@/lib/utils'
interface AlertBadgeProps {
count: number
variant?: 'default' | 'dot'
}
export function AlertBadge({ count, variant = 'default' }: AlertBadgeProps) {
if (count === 0) {
return null
}
if (variant === 'dot') {
return (
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-red-500 animate-pulse" />
)
}
return (
<span
className={cn(
'inline-flex items-center justify-center min-w-[20px] h-5 px-1.5',
'rounded-full bg-red-500 text-white text-xs font-medium',
'animate-pulse'
)}
>
{count > 99 ? '99+' : count}
</span>
)
}

View File

@@ -0,0 +1,112 @@
'use client'
import { useState } from 'react'
import { Check, X } from 'lucide-react'
import { Alert } from '@/lib/types'
import { cn } from '@/lib/utils'
import { AlertIcon } from './AlertIcon'
interface AlertBannerProps {
alert: Alert
onDismiss: () => void
onMarkRead: () => void
}
const severityStyles = {
info: {
bg: 'bg-blue-900/50',
border: 'border-l-blue-500',
icon: 'text-blue-400',
},
warning: {
bg: 'bg-amber-900/50',
border: 'border-l-amber-500',
icon: 'text-amber-400',
},
danger: {
bg: 'bg-red-900/50',
border: 'border-l-red-500',
icon: 'text-red-400',
},
}
export function AlertBanner({ alert, onDismiss, onMarkRead }: AlertBannerProps) {
const [isVisible, setIsVisible] = useState(true)
const [isExiting, setIsExiting] = useState(false)
const styles = severityStyles[alert.severity]
const handleDismiss = () => {
setIsExiting(true)
setTimeout(() => {
setIsVisible(false)
onDismiss()
}, 300)
}
const handleMarkRead = () => {
setIsExiting(true)
setTimeout(() => {
setIsVisible(false)
onMarkRead()
}, 300)
}
if (!isVisible) {
return null
}
return (
<div
className={cn(
'relative overflow-hidden rounded-r-lg border-l-4 p-4',
'transition-all duration-300 ease-out',
'animate-in slide-in-from-top-2',
isExiting && 'animate-out slide-out-to-top-2 opacity-0',
styles.bg,
styles.border
)}
role="alert"
>
<div className="flex items-start gap-3">
<div className={cn('flex-shrink-0 mt-0.5', styles.icon)}>
<AlertIcon type={alert.type} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-white text-sm">{alert.title}</h4>
<p className="mt-1 text-sm text-gray-300">{alert.message}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!alert.isRead && (
<button
onClick={handleMarkRead}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-400 hover:text-white hover:bg-white/10',
'focus:outline-none focus:ring-2 focus:ring-white/20'
)}
title="Marcar como leída"
aria-label="Marcar como leída"
>
<Check className="h-4 w-4" />
</button>
)}
<button
onClick={handleDismiss}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-400 hover:text-white hover:bg-white/10',
'focus:outline-none focus:ring-2 focus:ring-white/20'
)}
title="Cerrar"
aria-label="Cerrar alerta"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { Info, AlertTriangle, AlertCircle, LucideIcon } from 'lucide-react'
import { Alert } from '@/lib/types'
import { cn } from '@/lib/utils'
interface AlertIconProps {
type: Alert['type']
className?: string
}
const iconMap: Record<Alert['type'], LucideIcon> = {
PAYMENT_DUE: AlertCircle,
BUDGET_WARNING: AlertTriangle,
CARD_CLOSING: Info,
CARD_DUE: AlertCircle,
SAVINGS_GOAL: Info,
UNUSUAL_SPENDING: AlertTriangle,
}
export function AlertIcon({ type, className }: AlertIconProps) {
const Icon = iconMap[type]
return <Icon className={cn('h-5 w-5', className)} />
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState } from 'react'
import { Check, Trash2 } from 'lucide-react'
import { Alert } from '@/lib/types'
import { cn } from '@/lib/utils'
import { AlertIcon } from './AlertIcon'
interface AlertItemProps {
alert: Alert
onMarkRead: () => void
onDelete: () => void
}
const severityStyles = {
info: 'text-blue-400',
warning: 'text-amber-400',
danger: 'text-red-400',
}
function getRelativeTime(date: string): string {
const now = new Date()
const alertDate = new Date(date)
const diffMs = now.getTime() - alertDate.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) {
return 'ahora'
}
if (diffMins < 60) {
return `hace ${diffMins} min`
}
if (diffHours < 24) {
return `hace ${diffHours} hora${diffHours > 1 ? 's' : ''}`
}
if (diffDays === 1) {
return 'ayer'
}
if (diffDays < 7) {
return `hace ${diffDays} días`
}
return alertDate.toLocaleDateString('es-AR', {
day: 'numeric',
month: 'short',
})
}
export function AlertItem({ alert, onMarkRead, onDelete }: AlertItemProps) {
const [showActions, setShowActions] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const handleMarkRead = () => {
setIsExiting(true)
setTimeout(() => {
onMarkRead()
}, 200)
}
const handleDelete = () => {
setIsExiting(true)
setTimeout(() => {
onDelete()
}, 200)
}
return (
<div
className={cn(
'group relative flex items-center gap-3 p-3 rounded-lg',
'transition-all duration-200',
'hover:bg-white/5',
isExiting && 'opacity-0 -translate-x-4',
!alert.isRead && 'bg-white/[0.02]'
)}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
role="listitem"
>
{/* Unread indicator */}
{!alert.isRead && (
<span className="absolute left-1 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-blue-500" />
)}
{/* Icon */}
<div
className={cn(
'flex-shrink-0',
severityStyles[alert.severity],
!alert.isRead && 'ml-3'
)}
>
<AlertIcon type={alert.type} className="h-4 w-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={cn(
'text-sm truncate',
alert.isRead ? 'text-gray-400' : 'text-white font-medium'
)}
>
{alert.title}
</p>
<p className="text-xs text-gray-500 mt-0.5">{getRelativeTime(alert.date)}</p>
</div>
{/* Actions */}
<div
className={cn(
'flex items-center gap-1 transition-opacity duration-200',
showActions ? 'opacity-100' : 'opacity-0'
)}
>
{!alert.isRead && (
<button
onClick={handleMarkRead}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-500 hover:text-green-400 hover:bg-green-400/10',
'focus:outline-none focus:ring-2 focus:ring-green-400/20'
)}
title="Marcar como leída"
aria-label="Marcar como leída"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={handleDelete}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-500 hover:text-red-400 hover:bg-red-400/10',
'focus:outline-none focus:ring-2 focus:ring-red-400/20'
)}
title="Eliminar"
aria-label="Eliminar alerta"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState } from 'react'
import { Bell, CheckCheck, Trash2, Inbox } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useFinanzasStore } from '@/lib/store'
import { AlertItem } from './AlertItem'
import { AlertBadge } from './AlertBadge'
type TabType = 'all' | 'unread'
export function AlertPanel() {
const [activeTab, setActiveTab] = useState<TabType>('all')
const alerts = useFinanzasStore((state) => state.alerts)
const markAlertAsRead = useFinanzasStore((state) => state.markAlertAsRead)
const deleteAlert = useFinanzasStore((state) => state.deleteAlert)
const clearAllAlerts = useFinanzasStore((state) => state.clearAllAlerts)
const unreadAlerts = alerts.filter((alert) => !alert.isRead)
const unreadCount = unreadAlerts.length
const displayedAlerts = activeTab === 'unread' ? unreadAlerts : alerts
const handleMarkAllRead = () => {
unreadAlerts.forEach((alert) => {
markAlertAsRead(alert.id)
})
}
const handleClearAll = () => {
clearAllAlerts()
}
return (
<div className="w-full max-w-md bg-gray-900 rounded-xl border border-gray-800 shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<div className="relative">
<Bell className="h-5 w-5 text-gray-400" />
{unreadCount > 0 && <AlertBadge count={unreadCount} variant="dot" />}
</div>
<h3 className="font-semibold text-white">Alertas</h3>
{unreadCount > 0 && (
<span className="text-xs text-gray-500">({unreadCount})</span>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className={cn(
'p-2 rounded-md transition-colors',
'text-gray-500 hover:text-green-400 hover:bg-green-400/10',
'focus:outline-none focus:ring-2 focus:ring-green-400/20'
)}
title="Marcar todas como leídas"
aria-label="Marcar todas como leídas"
>
<CheckCheck className="h-4 w-4" />
</button>
)}
{alerts.length > 0 && (
<button
onClick={handleClearAll}
className={cn(
'p-2 rounded-md transition-colors',
'text-gray-500 hover:text-red-400 hover:bg-red-400/10',
'focus:outline-none focus:ring-2 focus:ring-red-400/20'
)}
title="Limpiar todas"
aria-label="Limpiar todas las alertas"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Tabs */}
{alerts.length > 0 && (
<div className="flex border-b border-gray-800">
<button
onClick={() => setActiveTab('all')}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500/20',
activeTab === 'all'
? 'text-white border-b-2 border-blue-500'
: 'text-gray-500 hover:text-gray-300'
)}
>
Todas
<span className="ml-1.5 text-xs text-gray-600">({alerts.length})</span>
</button>
<button
onClick={() => setActiveTab('unread')}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500/20',
activeTab === 'unread'
? 'text-white border-b-2 border-blue-500'
: 'text-gray-500 hover:text-gray-300'
)}
>
No leídas
{unreadCount > 0 && (
<span className="ml-1.5 text-xs text-blue-400">({unreadCount})</span>
)}
</button>
</div>
)}
{/* Alert List */}
<div className="max-h-[400px] overflow-y-auto">
{displayedAlerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="h-12 w-12 rounded-full bg-gray-800 flex items-center justify-center mb-3">
<Inbox className="h-6 w-6 text-gray-600" />
</div>
<p className="text-gray-400 text-sm">
{activeTab === 'unread'
? 'No tienes alertas sin leer'
: 'No tienes alertas'}
</p>
<p className="text-gray-600 text-xs mt-1">
Las alertas aparecerán cuando haya pagos próximos o eventos importantes
</p>
</div>
) : (
<div className="divide-y divide-gray-800/50" role="list">
{displayedAlerts.map((alert) => (
<AlertItem
key={alert.id}
alert={alert}
onMarkRead={() => markAlertAsRead(alert.id)}
onDelete={() => deleteAlert(alert.id)}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { AlertBanner } from './AlertBanner'
export { AlertItem } from './AlertItem'
export { AlertPanel } from './AlertPanel'
export { AlertBadge } from './AlertBadge'
export { AlertIcon } from './AlertIcon'
export { useAlerts } from './useAlerts'

View File

@@ -0,0 +1,68 @@
'use client'
import { useMemo, useCallback } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { generateAlerts, GenerateAlertsParams } from '@/lib/alerts'
export function useAlerts() {
const alerts = useFinanzasStore((state) => state.alerts)
const addAlert = useFinanzasStore((state) => state.addAlert)
const clearAllAlerts = useFinanzasStore((state) => state.clearAllAlerts)
const fixedDebts = useFinanzasStore((state) => state.fixedDebts)
const variableDebts = useFinanzasStore((state) => state.variableDebts)
const creditCards = useFinanzasStore((state) => state.creditCards)
const monthlyBudgets = useFinanzasStore((state) => state.monthlyBudgets)
const currentMonth = useFinanzasStore((state) => state.currentMonth)
const currentYear = useFinanzasStore((state) => state.currentYear)
const unreadAlerts = useMemo(
() => alerts.filter((alert) => !alert.isRead),
[alerts]
)
const unreadCount = unreadAlerts.length
const regenerateAlerts = useCallback(() => {
const params: GenerateAlertsParams = {
fixedDebts,
variableDebts,
creditCards,
monthlyBudgets,
currentMonth,
currentYear,
}
const newAlerts = generateAlerts(params)
// Clear existing alerts and add new ones
clearAllAlerts()
newAlerts.forEach((alertDraft) => {
addAlert({ ...alertDraft, isRead: false })
})
return newAlerts.length
}, [
fixedDebts,
variableDebts,
creditCards,
monthlyBudgets,
currentMonth,
currentYear,
clearAllAlerts,
addAlert,
])
const dismissAll = useCallback(() => {
clearAllAlerts()
}, [clearAllAlerts])
return {
alerts,
unreadCount,
unreadAlerts,
regenerateAlerts,
dismissAll,
}
}