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:
140
components/debts/DebtCard.tsx
Normal file
140
components/debts/DebtCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
224
components/debts/DebtSection.tsx
Normal file
224
components/debts/DebtSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
212
components/debts/FixedDebtForm.tsx
Normal file
212
components/debts/FixedDebtForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
components/debts/VariableDebtForm.tsx
Normal file
197
components/debts/VariableDebtForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
components/debts/index.ts
Normal file
4
components/debts/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DebtCard } from './DebtCard';
|
||||
export { DebtSection } from './DebtSection';
|
||||
export { FixedDebtForm } from './FixedDebtForm';
|
||||
export { VariableDebtForm } from './VariableDebtForm';
|
||||
Reference in New Issue
Block a user