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,
}
}

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';

View File

@@ -0,0 +1,255 @@
'use client'
import { useState } from 'react'
import { X, Check } from 'lucide-react'
interface CardPaymentFormData {
description: string
amount: number
date: string
installments?: {
current: number
total: number
}
}
interface CardPaymentFormProps {
cardId: string
onSubmit: (data: CardPaymentFormData) => void
onCancel: () => void
}
export function CardPaymentForm({ cardId, onSubmit, onCancel }: CardPaymentFormProps) {
const today = new Date().toISOString().split('T')[0]
const [formData, setFormData] = useState<CardPaymentFormData>({
description: '',
amount: 0,
date: today,
installments: undefined,
})
const [hasInstallments, setHasInstallments] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.description.trim()) {
newErrors.description = 'La descripción es requerida'
}
if (formData.amount <= 0) {
newErrors.amount = 'El monto debe ser mayor a 0'
}
if (!formData.date) {
newErrors.date = 'La fecha es requerida'
}
if (hasInstallments && formData.installments) {
if (formData.installments.current < 1) {
newErrors.installmentCurrent = 'La cuota actual debe ser al menos 1'
}
if (formData.installments.total < 2) {
newErrors.installmentTotal = 'El total de cuotas debe ser al menos 2'
}
if (formData.installments.current > formData.installments.total) {
newErrors.installments = 'La cuota actual no puede ser mayor al total'
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validateForm()) {
onSubmit({
...formData,
installments: hasInstallments ? formData.installments : undefined,
})
}
}
const updateField = (field: keyof CardPaymentFormData, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
const updateInstallmentField = (field: 'current' | 'total', value: number) => {
setFormData((prev) => ({
...prev,
installments: {
current: field === 'current' ? value : (prev.installments?.current ?? 1),
total: field === 'total' ? value : (prev.installments?.total ?? 1),
},
}))
// Clear related errors
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[`installment${field.charAt(0).toUpperCase() + field.slice(1)}`]
delete newErrors.installments
return newErrors
})
}
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl bg-slate-800 p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Registrar Pago</h3>
<button
type="button"
onClick={onCancel}
className="rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-700 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="grid gap-4">
{/* Description */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Descripción
</label>
<input
type="text"
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Ej: Supermercado Coto"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.description && (
<p className="mt-1 text-sm text-rose-400">{errors.description}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* Amount */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Monto
</label>
<input
type="number"
min={0}
step="0.01"
value={formData.amount || ''}
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.amount && (
<p className="mt-1 text-sm text-rose-400">{errors.amount}</p>
)}
</div>
{/* Date */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Fecha
</label>
<input
type="date"
value={formData.date}
onChange={(e) => updateField('date', e.target.value)}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.date && <p className="mt-1 text-sm text-rose-400">{errors.date}</p>}
</div>
</div>
{/* Installments Toggle */}
<div className="flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-700/50 p-3">
<input
type="checkbox"
id="hasInstallments"
checked={hasInstallments}
onChange={(e) => {
setHasInstallments(e.target.checked)
if (!e.target.checked) {
setFormData((prev) => ({ ...prev, installments: undefined }))
} else {
setFormData((prev) => ({ ...prev, installments: { current: 1, total: 1 } }))
}
}}
className="h-4 w-4 rounded border-slate-500 bg-slate-600 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="hasInstallments" className="text-sm font-medium text-slate-300">
Este pago es en cuotas
</label>
</div>
{/* Installments Fields */}
{hasInstallments && (
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Cuota actual
</label>
<input
type="number"
min={1}
value={formData.installments?.current || ''}
onChange={(e) =>
updateInstallmentField('current', parseInt(e.target.value) || 1)
}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.installmentCurrent && (
<p className="mt-1 text-sm text-rose-400">{errors.installmentCurrent}</p>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Total de cuotas
</label>
<input
type="number"
min={2}
value={formData.installments?.total || ''}
onChange={(e) =>
updateInstallmentField('total', parseInt(e.target.value) || 2)
}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.installmentTotal && (
<p className="mt-1 text-sm text-rose-400">{errors.installmentTotal}</p>
)}
</div>
</div>
)}
{errors.installments && (
<p className="text-sm text-rose-400">{errors.installments}</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-700"
>
Cancelar
</button>
<button
type="submit"
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Check className="h-4 w-4" />
Registrar pago
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,325 @@
'use client'
import { useState, useMemo } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { CreditCardWidget } from './CreditCardWidget'
import { CreditCardForm } from './CreditCardForm'
import { CardPaymentForm } from './CardPaymentForm'
import { MiniCard } from './MiniCard'
import { CreditCard, CardPayment } from '@/lib/types'
import { formatCurrency, formatShortDate, getMonthName } from '@/lib/utils'
import { Plus, CreditCard as CreditCardIcon, Receipt, Trash2 } from 'lucide-react'
export function CardSection() {
const {
creditCards,
cardPayments,
currentMonth,
currentYear,
addCreditCard,
updateCreditCard,
deleteCreditCard,
addCardPayment,
deleteCardPayment,
} = useFinanzasStore()
const [showCardForm, setShowCardForm] = useState(false)
const [editingCard, setEditingCard] = useState<CreditCard | null>(null)
const [selectedCardId, setSelectedCardId] = useState<string>('')
const [showPaymentForm, setShowPaymentForm] = useState(false)
// Filter payments for current month
const currentMonthPayments = useMemo(() => {
return cardPayments.filter((payment) => {
const paymentDate = new Date(payment.date)
return (
paymentDate.getMonth() + 1 === currentMonth &&
paymentDate.getFullYear() === currentYear
)
})
}, [cardPayments, currentMonth, currentYear])
const handleCardSubmit = (data: Omit<CreditCard, 'id'>) => {
if (editingCard) {
updateCreditCard(editingCard.id, data)
setEditingCard(null)
} else {
addCreditCard(data)
}
setShowCardForm(false)
}
const handleEditCard = (card: CreditCard) => {
setEditingCard(card)
setShowCardForm(true)
}
const handleDeleteCard = (cardId: string) => {
if (window.confirm('¿Estás seguro de que deseas eliminar esta tarjeta?')) {
deleteCreditCard(cardId)
if (selectedCardId === cardId) {
setSelectedCardId('')
setShowPaymentForm(false)
}
}
}
const handlePaymentSubmit = (data: {
description: string
amount: number
date: string
installments?: { current: number; total: number }
}) => {
addCardPayment({
cardId: selectedCardId,
...data,
})
setShowPaymentForm(false)
}
const handleDeletePayment = (paymentId: string) => {
if (window.confirm('¿Estás seguro de que deseas eliminar este pago?')) {
deleteCardPayment(paymentId)
}
}
const getCardById = (cardId: string): CreditCard | undefined => {
return creditCards.find((card) => card.id === cardId)
}
const getCardTotalPayments = (cardId: string): number => {
return currentMonthPayments
.filter((payment) => payment.cardId === cardId)
.reduce((total, payment) => total + payment.amount, 0)
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-500/20">
<CreditCardIcon className="h-5 w-5 text-indigo-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">Tarjetas de Crédito</h2>
<p className="text-sm text-slate-400">
{getMonthName(currentMonth)} {currentYear}
</p>
</div>
</div>
<button
onClick={() => {
setEditingCard(null)
setShowCardForm(true)
}}
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Plus className="h-4 w-4" />
Agregar tarjeta
</button>
</div>
{/* Cards Grid */}
{creditCards.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-600 bg-slate-800/50 p-12 text-center">
<CreditCardIcon className="mx-auto h-12 w-12 text-slate-500" />
<h3 className="mt-4 text-lg font-medium text-slate-300">
No tienes tarjetas registradas
</h3>
<p className="mt-1 text-sm text-slate-400">
Agrega tu primera tarjeta para comenzar a gestionar tus pagos
</p>
<button
onClick={() => {
setEditingCard(null)
setShowCardForm(true)
}}
className="mt-4 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Agregar tarjeta
</button>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{creditCards.map((card) => (
<CreditCardWidget
key={card.id}
card={card}
onEdit={() => handleEditCard(card)}
onDelete={() => handleDeleteCard(card.id)}
/>
))}
</div>
)}
{/* Card Form Modal */}
{showCardForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg">
<CreditCardForm
initialData={editingCard ?? undefined}
onSubmit={handleCardSubmit}
onCancel={() => {
setShowCardForm(false)
setEditingCard(null)
}}
/>
</div>
</div>
)}
{/* Payment Section */}
{creditCards.length > 0 && (
<div className="grid gap-6 lg:grid-cols-2">
{/* Register Payment */}
<div className="rounded-xl bg-slate-800 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20">
<Receipt className="h-4 w-4 text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-white">Registrar Pago</h3>
</div>
{/* Card Selector */}
<div className="mb-4">
<label className="mb-2 block text-sm font-medium text-slate-300">
Seleccionar tarjeta
</label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{creditCards.map((card) => (
<MiniCard
key={card.id}
card={card}
selected={selectedCardId === card.id}
onClick={() => {
setSelectedCardId(card.id)
setShowPaymentForm(true)
}}
/>
))}
</div>
</div>
{/* Payment Form */}
{showPaymentForm && selectedCardId && (
<CardPaymentForm
cardId={selectedCardId}
onSubmit={handlePaymentSubmit}
onCancel={() => {
setShowPaymentForm(false)
setSelectedCardId('')
}}
/>
)}
</div>
{/* Recent Payments */}
<div className="rounded-xl bg-slate-800 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/20">
<Receipt className="h-4 w-4 text-amber-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Pagos del Mes</h3>
<p className="text-sm text-slate-400">
Total: {formatCurrency(
currentMonthPayments.reduce((sum, p) => sum + p.amount, 0)
)}
</p>
</div>
</div>
{currentMonthPayments.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-600 p-8 text-center">
<p className="text-sm text-slate-400">
No hay pagos registrados este mes
</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{currentMonthPayments
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.map((payment) => {
const card = getCardById(payment.cardId)
return (
<div
key={payment.id}
className="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-700/50 p-4"
>
<div className="flex items-center gap-3">
{card && (
<div
className="h-8 w-8 shrink-0 rounded-md"
style={{ backgroundColor: card.color }}
/>
)}
<div className="min-w-0">
<p className="truncate font-medium text-white">
{payment.description}
</p>
<p className="text-xs text-slate-400">
{card?.name} {formatShortDate(payment.date)}
{payment.installments && (
<span className="ml-2 text-amber-400">
Cuota {payment.installments.current}/{payment.installments.total}
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="font-semibold text-white">
{formatCurrency(payment.amount)}
</span>
<button
onClick={() => handleDeletePayment(payment.id)}
className="rounded p-1 text-slate-400 transition-colors hover:bg-rose-500/20 hover:text-rose-400"
aria-label="Eliminar pago"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Summary by Card */}
{currentMonthPayments.length > 0 && (
<div className="mt-6 border-t border-slate-700 pt-4">
<h4 className="mb-3 text-sm font-medium text-slate-300">
Resumen por tarjeta
</h4>
<div className="space-y-2">
{creditCards.map((card) => {
const total = getCardTotalPayments(card.id)
if (total === 0) return null
return (
<div
key={card.id}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: card.color }}
/>
<span className="text-slate-300">{card.name}</span>
</div>
<span className="font-medium text-white">
{formatCurrency(total)}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,242 @@
'use client'
import { useState } from 'react'
import { CreditCard } from '@/lib/types'
import { X, Check } from 'lucide-react'
interface CreditCardFormProps {
initialData?: Partial<CreditCard>
onSubmit: (data: Omit<CreditCard, 'id'>) => void
onCancel: () => void
}
const DEFAULT_COLOR = '#6366f1'
export function CreditCardForm({ initialData, onSubmit, onCancel }: CreditCardFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name ?? '',
lastFourDigits: initialData?.lastFourDigits ?? '',
closingDay: initialData?.closingDay ?? 1,
dueDay: initialData?.dueDay ?? 10,
currentBalance: initialData?.currentBalance ?? 0,
creditLimit: initialData?.creditLimit ?? 0,
color: initialData?.color ?? DEFAULT_COLOR,
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido'
}
if (!formData.lastFourDigits.trim()) {
newErrors.lastFourDigits = 'Los últimos 4 dígitos son requeridos'
} else if (!/^\d{4}$/.test(formData.lastFourDigits)) {
newErrors.lastFourDigits = 'Debe ser exactamente 4 dígitos numéricos'
}
if (formData.closingDay < 1 || formData.closingDay > 31) {
newErrors.closingDay = 'El día debe estar entre 1 y 31'
}
if (formData.dueDay < 1 || formData.dueDay > 31) {
newErrors.dueDay = 'El día debe estar entre 1 y 31'
}
if (formData.creditLimit <= 0) {
newErrors.creditLimit = 'El límite de crédito debe ser mayor a 0'
}
if (formData.currentBalance < 0) {
newErrors.currentBalance = 'El balance no puede ser negativo'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validateForm()) {
onSubmit(formData)
}
}
const updateField = (field: keyof typeof formData, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
const handleLastFourDigitsChange = (value: string) => {
// Only allow digits and max 4 characters
const digitsOnly = value.replace(/\D/g, '').slice(0, 4)
updateField('lastFourDigits', digitsOnly)
}
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl bg-slate-800 p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">
{initialData ? 'Editar Tarjeta' : 'Nueva Tarjeta'}
</h3>
<button
type="button"
onClick={onCancel}
className="rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-700 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* Card Name */}
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-slate-300">
Nombre de la tarjeta
</label>
<input
type="text"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="Ej: Visa Banco Galicia"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.name && <p className="mt-1 text-sm text-rose-400">{errors.name}</p>}
</div>
{/* Last 4 Digits */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Últimos 4 dígitos
</label>
<input
type="text"
inputMode="numeric"
value={formData.lastFourDigits}
onChange={(e) => handleLastFourDigitsChange(e.target.value)}
placeholder="1234"
maxLength={4}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.lastFourDigits && (
<p className="mt-1 text-sm text-rose-400">{errors.lastFourDigits}</p>
)}
</div>
{/* Color Picker */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">Color</label>
<div className="flex items-center gap-3">
<input
type="color"
value={formData.color}
onChange={(e) => updateField('color', e.target.value)}
className="h-10 w-20 cursor-pointer rounded-lg border border-slate-600 bg-slate-700"
/>
<span className="text-sm text-slate-400">{formData.color}</span>
</div>
</div>
{/* Closing Day */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Día de cierre
</label>
<input
type="number"
min={1}
max={31}
value={formData.closingDay}
onChange={(e) => updateField('closingDay', parseInt(e.target.value) || 1)}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.closingDay && (
<p className="mt-1 text-sm text-rose-400">{errors.closingDay}</p>
)}
</div>
{/* Due Day */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Día de vencimiento
</label>
<input
type="number"
min={1}
max={31}
value={formData.dueDay}
onChange={(e) => updateField('dueDay', parseInt(e.target.value) || 1)}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.dueDay && <p className="mt-1 text-sm text-rose-400">{errors.dueDay}</p>}
</div>
{/* Credit Limit */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Límite de crédito
</label>
<input
type="number"
min={0}
step="0.01"
value={formData.creditLimit || ''}
onChange={(e) => updateField('creditLimit', parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.creditLimit && (
<p className="mt-1 text-sm text-rose-400">{errors.creditLimit}</p>
)}
</div>
{/* Current Balance */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Balance actual
</label>
<input
type="number"
min={0}
step="0.01"
value={formData.currentBalance || ''}
onChange={(e) => updateField('currentBalance', parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.currentBalance && (
<p className="mt-1 text-sm text-rose-400">{errors.currentBalance}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-700"
>
Cancelar
</button>
<button
type="submit"
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Check className="h-4 w-4" />
{initialData ? 'Guardar cambios' : 'Crear tarjeta'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { CreditCard } from '@/lib/types'
import { formatCurrency, getCardUtilization, getDaysUntil, calculateNextClosingDate, calculateNextDueDate } from '@/lib/utils'
import { Pencil, Trash2 } from 'lucide-react'
interface CreditCardWidgetProps {
card: CreditCard
onEdit: () => void
onDelete: () => void
}
export function CreditCardWidget({ card, onEdit, onDelete }: CreditCardWidgetProps) {
const utilization = getCardUtilization(card.currentBalance, card.creditLimit)
const nextClosing = calculateNextClosingDate(card.closingDay)
const nextDue = calculateNextDueDate(card.dueDay)
const daysUntilClosing = getDaysUntil(nextClosing)
const daysUntilDue = getDaysUntil(nextDue)
const getUtilizationColor = (util: number): string => {
if (util < 30) return 'bg-emerald-500'
if (util < 70) return 'bg-amber-500'
return 'bg-rose-500'
}
const getUtilizationTextColor = (util: number): string => {
if (util < 30) return 'text-emerald-400'
if (util < 70) return 'text-amber-400'
return 'text-rose-400'
}
return (
<div
className="relative overflow-hidden rounded-2xl p-6 text-white shadow-lg transition-transform hover:scale-[1.02]"
style={{
aspectRatio: '1.586',
background: `linear-gradient(135deg, ${card.color} 0%, ${adjustColor(card.color, -30)} 100%)`,
}}
>
{/* Decorative circles */}
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-white/10" />
<div className="absolute -bottom-12 -left-12 h-40 w-40 rounded-full bg-white/5" />
{/* Header with card name and actions */}
<div className="relative flex items-start justify-between">
<h3 className="text-lg font-semibold tracking-wide">{card.name}</h3>
<div className="flex gap-1">
<button
onClick={onEdit}
className="rounded-full p-1.5 transition-colors hover:bg-white/20"
aria-label="Editar tarjeta"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={onDelete}
className="rounded-full p-1.5 transition-colors hover:bg-white/20"
aria-label="Eliminar tarjeta"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Card number */}
<div className="relative mt-8">
<p className="font-mono text-2xl tracking-widest">
**** **** **** {card.lastFourDigits}
</p>
</div>
{/* Balance */}
<div className="relative mt-6">
<p className="text-sm text-white/70">Balance actual</p>
<p className="text-2xl font-bold">{formatCurrency(card.currentBalance)}</p>
</div>
{/* Utilization badge */}
<div className="relative mt-4 flex items-center gap-3">
<div className="flex items-center gap-2 rounded-full bg-black/30 px-3 py-1">
<div className={`h-2 w-2 rounded-full ${getUtilizationColor(utilization)}`} />
<span className={`text-sm font-medium ${getUtilizationTextColor(utilization)}`}>
{utilization.toFixed(0)}% usado
</span>
</div>
<span className="text-sm text-white/60">
de {formatCurrency(card.creditLimit)}
</span>
</div>
{/* Footer with closing and due dates */}
<div className="relative mt-4 flex justify-between text-xs text-white/70">
<div>
<span className="block">Cierre</span>
<span className="font-medium text-white">
{card.closingDay} ({daysUntilClosing === 0 ? 'hoy' : daysUntilClosing > 0 ? `en ${daysUntilClosing} días` : `hace ${Math.abs(daysUntilClosing)} días`})
</span>
</div>
<div className="text-right">
<span className="block">Vencimiento</span>
<span className="font-medium text-white">
{card.dueDay} ({daysUntilDue === 0 ? 'hoy' : daysUntilDue > 0 ? `en ${daysUntilDue} días` : `hace ${Math.abs(daysUntilDue)} días`})
</span>
</div>
</div>
</div>
)
}
/**
* Ajusta el brillo de un color hexadecimal
* @param color - Color en formato hex (#RRGGBB)
* @param amount - Cantidad a ajustar (negativo para oscurecer, positivo para aclarar)
* @returns Color ajustado en formato hex
*/
function adjustColor(color: string, amount: number): string {
const hex = color.replace('#', '')
const r = Math.max(0, Math.min(255, parseInt(hex.substring(0, 2), 16) + amount))
const g = Math.max(0, Math.min(255, parseInt(hex.substring(2, 4), 16) + amount))
const b = Math.max(0, Math.min(255, parseInt(hex.substring(4, 6), 16) + amount))
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}

View File

@@ -0,0 +1,43 @@
'use client'
import { CreditCard } from '@/lib/types'
import { Check } from 'lucide-react'
interface MiniCardProps {
card: CreditCard
selected?: boolean
onClick?: () => void
}
export function MiniCard({ card, selected = false, onClick }: MiniCardProps) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-all ${
selected
? 'border-indigo-500 bg-indigo-500/20 ring-2 ring-indigo-500/30'
: 'border-slate-600 bg-slate-800 hover:border-slate-500 hover:bg-slate-700'
}`}
>
{/* Color indicator */}
<div
className="h-10 w-10 shrink-0 rounded-lg shadow-inner"
style={{ backgroundColor: card.color }}
/>
{/* Card info */}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-white">{card.name}</p>
<p className="text-sm text-slate-400">**** {card.lastFourDigits}</p>
</div>
{/* Selected indicator */}
{selected && (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-500">
<Check className="h-4 w-4 text-white" />
</div>
)}
</button>
)
}

View File

@@ -0,0 +1,5 @@
export { CreditCardWidget } from './CreditCardWidget'
export { CreditCardForm } from './CreditCardForm'
export { CardPaymentForm } from './CardPaymentForm'
export { MiniCard } from './MiniCard'
export { CardSection } from './CardSection'

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'

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';

View File

@@ -0,0 +1,57 @@
'use client';
import { Menu } from 'lucide-react';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { Logo } from './Logo';
interface HeaderProps {
onMenuClick: () => void;
title: string;
}
export function Header({ onMenuClick, title }: HeaderProps) {
const currentDate = format(new Date(), "EEEE, d 'de' MMMM 'de' yyyy", {
locale: es,
});
// Capitalizar primera letra
const formattedDate =
currentDate.charAt(0).toUpperCase() + currentDate.slice(1);
return (
<header className="sticky top-0 z-30 bg-slate-900/95 backdrop-blur-sm border-b border-slate-800">
<div className="flex items-center justify-between h-16 px-4 md:px-6">
{/* Left section */}
<div className="flex items-center gap-4">
<button
onClick={onMenuClick}
className="lg:hidden p-2 -ml-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors"
aria-label="Abrir menú"
>
<Menu className="w-6 h-6" />
</button>
<div className="flex items-center gap-3">
<div className="lg:hidden">
<Logo size="sm" showText={false} />
</div>
<h1 className="text-lg md:text-xl font-semibold text-slate-100">
{title}
</h1>
</div>
</div>
{/* Right section */}
<div className="flex items-center gap-4">
<div className="hidden md:flex items-center gap-2">
<Logo size="sm" showText />
</div>
<time className="text-sm text-slate-400 hidden sm:block">
{formattedDate}
</time>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,36 @@
import { Wallet } from 'lucide-react';
interface LogoProps {
size?: 'sm' | 'md' | 'lg';
showText?: boolean;
}
const sizeMap = {
sm: {
icon: 24,
text: 'text-lg',
},
md: {
icon: 32,
text: 'text-xl',
},
lg: {
icon: 40,
text: 'text-2xl',
},
};
export function Logo({ size = 'md', showText = true }: LogoProps) {
const { icon, text } = sizeMap[size];
return (
<div className="flex items-center gap-2">
<div className="flex items-center justify-center">
<Wallet className="text-emerald-500" size={icon} strokeWidth={2} />
</div>
{showText && (
<span className={`font-bold text-slate-100 ${text}`}>Finanzas</span>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import {
LayoutDashboard,
Wallet,
CreditCard,
PiggyBank,
Bell,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface MobileNavProps {
unreadAlertsCount?: number;
}
const navigationItems = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Deudas', href: '/debts', icon: Wallet },
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
];
export function MobileNav({ unreadAlertsCount = 0 }: MobileNavProps) {
const pathname = usePathname();
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
return (
<nav className="fixed bottom-0 left-0 right-0 z-40 bg-slate-900 border-t border-slate-800 lg:hidden">
<ul className="flex items-center justify-around h-16">
{navigationItems.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
<li key={item.name} className="flex-1">
<Link
href={item.href}
className={`
flex flex-col items-center justify-center gap-1 py-2
transition-colors relative
${
active
? 'text-emerald-500'
: 'text-slate-400 hover:text-slate-300'
}
`}
>
<div className="relative">
<Icon className="w-6 h-6" />
{item.hasBadge && unreadAlertsCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-semibold bg-red-500 text-white rounded-full">
{unreadAlertsCount > 99 ? '99+' : unreadAlertsCount}
</span>
)}
</div>
<span className="text-[10px] font-medium">{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
interface PageContainerProps {
children: ReactNode;
title: string;
}
export function PageContainer({ children, title }: PageContainerProps) {
return (
<main className="min-h-screen bg-slate-950">
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
<div className="space-y-6">
{children}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,35 @@
import { ReactNode } from 'react';
interface SectionAction {
label: string;
onClick: () => void;
}
interface SectionProps {
title: string;
children: ReactNode;
action?: SectionAction;
}
export function Section({ title, children, action }: SectionProps) {
return (
<section className="bg-slate-900 rounded-lg border border-slate-800">
<div className="flex items-center justify-between px-4 py-3 md:px-6 md:py-4 border-b border-slate-800">
<h2 className="text-base md:text-lg font-semibold text-slate-100">
{title}
</h2>
{action && (
<button
onClick={action.onClick}
className="px-3 py-1.5 text-sm font-medium text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition-colors"
>
{action.label}
</button>
)}
</div>
<div className="p-4 md:p-6">
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import {
LayoutDashboard,
Wallet,
CreditCard,
PiggyBank,
Bell,
X,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Logo } from './Logo';
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
unreadAlertsCount?: number;
}
const navigationItems = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Deudas', href: '/debts', icon: Wallet },
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
];
export function Sidebar({
isOpen,
onClose,
unreadAlertsCount = 0,
}: SidebarProps) {
const pathname = usePathname();
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={onClose}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`
fixed top-0 left-0 z-50 h-full w-64 bg-slate-900 border-r border-slate-800
transform transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:static lg:h-screen
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800">
<Logo size="md" showText />
<button
onClick={onClose}
className="lg:hidden p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors"
aria-label="Cerrar menú"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3">
<ul className="space-y-1">
{navigationItems.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
<li key={item.name}>
<Link
href={item.href}
onClick={onClose}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors relative
${
active
? 'bg-slate-800 text-emerald-400 border-l-2 border-emerald-500'
: 'text-slate-300 hover:bg-slate-800 hover:text-slate-100'
}
`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="flex-1">{item.name}</span>
{item.hasBadge && unreadAlertsCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold bg-red-500 text-white rounded-full">
{unreadAlertsCount > 99 ? '99+' : unreadAlertsCount}
</span>
)}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Footer */}
<div className="p-4 border-t border-slate-800">
<p className="text-xs text-slate-500 text-center">
Finanzas v{process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0'}
</p>
</div>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,6 @@
export { Sidebar } from './Sidebar';
export { Header } from './Header';
export { MobileNav } from './MobileNav';
export { Logo } from './Logo';
export { PageContainer } from './PageContainer';
export { Section } from './Section';

View File

@@ -0,0 +1,196 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { X, CreditCard, Calendar, DollarSign, Palette } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AddCardModalProps {
isOpen: boolean
onClose: () => void
}
const COLORS = [
{ name: 'Slate', value: '#64748b' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Cyan', value: '#06b6d4' },
{ name: 'Emerald', value: '#10b981' },
{ name: 'Violet', value: '#8b5cf6' },
{ name: 'Rose', value: '#f43f5e' },
{ name: 'Amber', value: '#f59e0b' },
]
export function AddCardModal({ isOpen, onClose }: AddCardModalProps) {
const addCreditCard = useFinanzasStore((state) => state.addCreditCard)
const [name, setName] = useState('')
const [lastFour, setLastFour] = useState('')
const [limit, setLimit] = useState('')
const [closingDay, setClosingDay] = useState('')
const [dueDay, setDueDay] = useState('')
const [selectedColor, setSelectedColor] = useState(COLORS[1].value)
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !limit || !closingDay || !dueDay) return
addCreditCard({
name,
lastFourDigits: lastFour || '****',
closingDay: parseInt(closingDay),
dueDay: parseInt(dueDay),
currentBalance: 0,
creditLimit: parseFloat(limit),
color: selectedColor
})
// Reset
setName('')
setLastFour('')
setLimit('')
setClosingDay('')
setDueDay('')
setSelectedColor(COLORS[1].value)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
<CreditCard className="text-cyan-500" /> Nueva Tarjeta
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Name & Last 4 */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Nombre Banco / Tarjeta</label>
<input
type="text"
placeholder="Ej: Visa Santander"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
autoFocus
/>
</div>
<div className="col-span-1 space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Ult. 4 Dig.</label>
<input
type="text"
maxLength={4}
placeholder="1234"
value={lastFour}
onChange={(e) => setLastFour(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none text-center tracking-widest"
/>
</div>
</div>
{/* Limit */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Límite de Crédito</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
<input
type="number"
step="0.01"
placeholder="0.00"
value={limit}
onChange={(e) => setLimit(e.target.value)}
className="w-full pl-8 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white text-lg font-mono outline-none"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Closing Day */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Día Cierre</label>
<div className="relative">
<input
type="number"
min="1"
max="31"
placeholder="20"
value={closingDay}
onChange={(e) => setClosingDay(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
</div>
</div>
{/* Due Day */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Día Vencimiento</label>
<div className="relative">
<input
type="number"
min="1"
max="31"
placeholder="5"
value={dueDay}
onChange={(e) => setDueDay(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
</div>
</div>
</div>
{/* Color Picker */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Palette size={12} /> Color de Tarjeta
</label>
<div className="flex gap-3 overflow-x-auto pb-2">
{COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all",
selectedColor === color.value
? "border-white scale-110 shadow-lg"
: "border-transparent opacity-70 hover:opacity-100 hover:scale-105"
)}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
>
Crear Tarjeta
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,232 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { v4 as uuidv4 } from 'uuid'
import { X, Calendar, DollarSign, Tag, FileText, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AddDebtModalProps {
isOpen: boolean
onClose: () => void
}
type DebtType = 'fixed' | 'variable'
export function AddDebtModal({ isOpen, onClose }: AddDebtModalProps) {
const [activeTab, setActiveTab] = useState<DebtType>('variable')
const [name, setName] = useState('')
const [amount, setAmount] = useState('')
const [dateStr, setDateStr] = useState(new Date().toISOString().split('T')[0]) // For variable: YYYY-MM-DD
const [dueDay, setDueDay] = useState('1') // For fixed: 1-31
const [categoryFixed, setCategoryFixed] = useState('housing')
const [categoryVariable, setCategoryVariable] = useState('shopping')
const [isAutoDebit, setIsAutoDebit] = useState(false)
const [notes, setNotes] = useState('')
const addFixedDebt = useFinanzasStore((state) => state.addFixedDebt)
const addVariableDebt = useFinanzasStore((state) => state.addVariableDebt)
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !amount) return
const numAmount = parseFloat(amount)
if (isNaN(numAmount)) return
if (activeTab === 'fixed') {
addFixedDebt({
name,
amount: numAmount,
dueDay: parseInt(dueDay),
category: categoryFixed as any,
isAutoDebit,
isPaid: false,
notes: notes || undefined
})
} else {
addVariableDebt({
name,
amount: numAmount,
date: new Date(dateStr).toISOString(),
category: categoryVariable as any,
isPaid: false,
notes: notes || undefined
})
}
// Reset and Close
setName('')
setAmount('')
setNotes('')
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h2 className="text-xl font-semibold text-white">Agregar Gasto / Deuda</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="flex p-1 mx-6 mt-6 bg-slate-800/50 rounded-lg">
<button
onClick={() => setActiveTab('variable')}
className={cn(
"flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200",
activeTab === 'variable' ? "bg-cyan-500 text-white shadow-lg" : "text-slate-400 hover:text-white"
)}
>
Variable (Único)
</button>
<button
onClick={() => setActiveTab('fixed')}
className={cn(
"flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200",
activeTab === 'fixed' ? "bg-cyan-500 text-white shadow-lg" : "text-slate-400 hover:text-white"
)}
>
Fijo (Recurrente)
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Amount Input */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
<input
type="number"
step="0.01"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full pl-8 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white text-lg font-mono outline-none transition-all placeholder:text-slate-600"
required
autoFocus
/>
</div>
</div>
{/* Name Input */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Descripción</label>
<input
type="text"
placeholder="Ej: Supermercado Coto, Netflix, Alquiler"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none transition-all placeholder:text-slate-600"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Category Select */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<Tag size={12} /> Categoría
</label>
<select
value={activeTab === 'fixed' ? categoryFixed : categoryVariable}
onChange={(e) => activeTab === 'fixed' ? setCategoryFixed(e.target.value) : setCategoryVariable(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none appearance-none cursor-pointer"
>
{activeTab === 'fixed' ? (
<>
<option value="housing">Vivienda</option>
<option value="services">Servicios</option>
<option value="subscription">Suscripciones</option>
<option value="other">Otro</option>
</>
) : (
<>
<option value="food">Comida / Super</option>
<option value="shopping">Compras</option>
<option value="transport">Transporte</option>
<option value="health">Salud</option>
<option value="entertainment">Entretenimiento</option>
<option value="other">Otro</option>
</>
)}
</select>
</div>
{/* Date/DueDay Input */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<Calendar size={12} /> {activeTab === 'fixed' ? 'Día Vencimiento' : 'Fecha'}
</label>
{activeTab === 'fixed' ? (
<div className="relative">
<input
type="number"
min="1"
max="31"
value={dueDay}
onChange={(e) => setDueDay(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
</div>
) : (
<input
type="date"
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
required
/>
)}
</div>
</div>
{activeTab === 'fixed' && (
<div className="flex items-center gap-2 px-4 py-3 bg-slate-800/30 rounded-lg cursor-pointer" onClick={() => setIsAutoDebit(!isAutoDebit)}>
<div className={cn("w-5 h-5 rounded border flex items-center justify-center transition-colors", isAutoDebit ? "bg-cyan-500 border-cyan-500" : "border-slate-600 bg-transparent")}>
{isAutoDebit && <CheckCircle2 size={14} className="text-white" />}
</div>
<span className="text-sm text-slate-300 select-none">Débito Automático</span>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<FileText size={12} /> Notas (Opcional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Detalles adicionales..."
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none min-h-[80px] text-sm resize-none placeholder:text-slate-600"
/>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
>
Agregar {activeTab === 'fixed' ? 'Gasto Fijo' : 'Gasto'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { X, CreditCard, DollarSign, Calendar, FileText, Layers } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AddPaymentModalProps {
isOpen: boolean
onClose: () => void
}
export function AddPaymentModal({ isOpen, onClose }: AddPaymentModalProps) {
const cards = useFinanzasStore((state) => state.creditCards)
const addCardPayment = useFinanzasStore((state) => state.addCardPayment)
const [selectedCardId, setSelectedCardId] = useState(cards[0]?.id || '')
const [description, setDescription] = useState('')
const [amount, setAmount] = useState('')
const [dateStr, setDateStr] = useState(new Date().toISOString().split('T')[0])
const [hasInstallments, setHasInstallments] = useState(false)
const [installments, setInstallments] = useState('1')
const [totalInstallments, setTotalInstallments] = useState('12')
if (!isOpen) return null
// Ensure card selection if cards exist
if (!selectedCardId && cards.length > 0) {
setSelectedCardId(cards[0].id)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!description || !amount || !selectedCardId) return
addCardPayment({
cardId: selectedCardId,
amount: parseFloat(amount),
date: new Date(dateStr).toISOString(),
description,
installments: hasInstallments ? {
current: parseInt(installments),
total: parseInt(totalInstallments)
} : undefined
})
// Reset
setDescription('')
setAmount('')
setHasInstallments(false)
setInstallments('1')
setTotalInstallments('12')
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h2 className="text-xl font-semibold text-white">Registrar Consumo / Pago</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{cards.length === 0 ? (
<div className="p-8 text-center space-y-4">
<CreditCard className="mx-auto text-slate-600 mb-2" size={48} />
<h3 className="text-lg font-medium text-white">No tienes tarjetas registradas</h3>
<p className="text-slate-400">Debes agregar una tarjeta antes de registrar pagos.</p>
<button
onClick={onClose}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg transition"
>
Entendido
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Card Selection */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Tarjeta</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[120px] overflow-y-auto pr-1">
{cards.map((card) => (
<div
key={card.id}
onClick={() => setSelectedCardId(card.id)}
className={cn(
"cursor-pointer p-3 rounded-lg border flex items-center gap-3 transition-all",
selectedCardId === card.id
? "border-cyan-500 bg-cyan-500/10 ring-1 ring-cyan-500"
: "border-slate-800 bg-slate-950 hover:border-slate-700"
)}
>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: card.color }} />
<div className="flex flex-col truncate">
<span className="text-sm font-medium text-white truncate">{card.name}</span>
<span className="text-xs text-slate-500">**** {card.lastFourDigits}</span>
</div>
</div>
))}
</div>
</div>
{/* Amount */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
<input
type="number"
step="0.01"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full pl-8 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white text-lg font-mono outline-none"
required
autoFocus
/>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Descripción</label>
<input
type="text"
placeholder="Ej: Cena McDonalds, Compra ML"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
</div>
{/* Date */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<Calendar size={12} /> Fecha
</label>
<input
type="date"
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
required
/>
</div>
{/* Installments Toggle */}
<div className="flex items-center gap-2 px-4 py-3 bg-slate-800/30 rounded-lg cursor-pointer" onClick={() => setHasInstallments(!hasInstallments)}>
<div className={cn("w-5 h-5 rounded border flex items-center justify-center transition-colors", hasInstallments ? "bg-cyan-500 border-cyan-500" : "border-slate-600 bg-transparent")}>
{hasInstallments && <Layers size={14} className="text-white" />}
</div>
<span className="text-sm text-slate-300 select-none">Es una compra en cuotas</span>
</div>
{/* Installments Inputs */}
{hasInstallments && (
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Cuota N°</label>
<input
type="number"
min="1"
value={installments}
onChange={(e) => setInstallments(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Total Cuotas</label>
<input
type="number"
min="1"
value={totalInstallments}
onChange={(e) => setTotalInstallments(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white"
/>
</div>
</div>
)}
<div className="pt-2">
<button
type="submit"
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
>
Registrar Pago
</button>
</div>
</form>
)}
</div>
</div>
)
}