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:
32
components/alerts/AlertBadge.tsx
Normal file
32
components/alerts/AlertBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
components/alerts/AlertBanner.tsx
Normal file
112
components/alerts/AlertBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
components/alerts/AlertIcon.tsx
Normal file
25
components/alerts/AlertIcon.tsx
Normal 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)} />
|
||||
}
|
||||
148
components/alerts/AlertItem.tsx
Normal file
148
components/alerts/AlertItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
components/alerts/AlertPanel.tsx
Normal file
148
components/alerts/AlertPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
components/alerts/index.ts
Normal file
6
components/alerts/index.ts
Normal 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'
|
||||
68
components/alerts/useAlerts.ts
Normal file
68
components/alerts/useAlerts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user