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:
255
components/cards/CardPaymentForm.tsx
Normal file
255
components/cards/CardPaymentForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
325
components/cards/CardSection.tsx
Normal file
325
components/cards/CardSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
components/cards/CreditCardForm.tsx
Normal file
242
components/cards/CreditCardForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
components/cards/CreditCardWidget.tsx
Normal file
123
components/cards/CreditCardWidget.tsx
Normal 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')}`
|
||||
}
|
||||
43
components/cards/MiniCard.tsx
Normal file
43
components/cards/MiniCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
components/cards/index.ts
Normal file
5
components/cards/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user