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