commit 712b06f1189d025cc40346490f8075c82d55271a Author: renato97 Date: Thu Jan 29 00:00:32 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40ead90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Testing +coverage + +# Next.js +.next/ +out/ +build + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# Local env files +.env*.local + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f9dbb7 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# 💰 Finanzas - Gestor Personal de Finanzas + +Una aplicación moderna y completa para la gestión de finanzas personales, construida con Next.js y TypeScript. Controla tus deudas, tarjetas de crédito, presupuestos y mantente al día con alertas inteligentes. + +![Dashboard Preview](./preview.png) + +## ✨ Características Principales + +### 🏠 Dashboard Central +- **Resumen financiero** con métricas clave +- **Actividad reciente** y tendencias +- **Acciones rápidas** para agregar gastos y pagos +- **Alertas inteligentes** personalizadas + +### 💳 Gestión de Tarjetas de Crédito +- Múltiples tarjetas con límites y saldos +- Registro de pagos y compras +- Seguimiento de cuotas e instalaciones +- Alertas de cierre y vencimiento + +### 📊 Control de Deudas +- **Deudas fijas**: Alquiler, servicios, suscripciones +- **Deudas variables**: Compras, entretenimiento, salud +- Categorización automática +- Marcado de pagos realizados +- Notas y recordatorios + +### 💰 Presupuesto Mensual +- Ingresos y gastos planificados +- Metas de ahorro +- Seguimiento en tiempo real +- Visualización con gráficos + +### 🚨 Sistema de Alertas +- Alertas de vencimiento de pagos +- Advertencias de presupuesto +- Recordatorios de cierre de tarjetas +- Detección de gastos inusuales +- Notificaciones de metas de ahorro + +### 📱 Diseño Responsivo +- Sidebar colapsible en desktop +- Navegación móvil intuitiva +- Interfaz moderna con Tailwind CSS +- Soporte completo para dispositivos móviles + +## 🛠️ Stack Tecnológico + +- **[Next.js 14](https://nextjs.org/)** - Framework React con SSR/SSG +- **[TypeScript](https://www.typescriptlang.org/)** - Tipado estático +- **[Tailwind CSS](https://tailwindcss.com/)** - Estilos utilitarios +- **[Zustand](https://github.com/pmndrs/zustand)** - Gestión de estado +- **[Recharts](https://recharts.org/)** - Gráficos y visualizaciones +- **[Lucide React](https://lucide.dev/)** - Iconos modernos + +## 🚀 Instalación y Uso + +### Prerrequisitos +- Node.js 18+ +- npm o yarn + +### 1. Clonar el repositorio +```bash +git clone https://gitea.cbcren.online/renato97/finanzas.git +cd finanzas +``` + +### 2. Instalar dependencias +```bash +npm install +``` + +### 3. Ejecutar en desarrollo +```bash +npm run dev +``` + +La aplicación estará disponible en `http://localhost:3000` + +### 4. Construir para producción +```bash +npm run build +``` + +Los archivos estáticos se generarán en el directorio `dist/` + +## 📁 Estructura del Proyecto + +``` +finanzas/ +├── app/ # Rutas y páginas de Next.js +│ ├── alerts/ # Página de alertas +│ ├── budget/ # Página de presupuesto +│ ├── cards/ # Página de tarjetas +│ ├── debts/ # Página de deudas +│ └── page.tsx # Dashboard principal +├── components/ # Componentes reutilizables +│ ├── alerts/ # Sistema de alertas +│ ├── budget/ # Componentes de presupuesto +│ ├── cards/ # Componentes de tarjetas +│ ├── dashboard/ # Componentes del dashboard +│ ├── debts/ # Componentes de deudas +│ ├── layout/ # Layout y navegación +│ └── modals/ # Modales de creación/edición +├── lib/ # Utilidades y store +│ ├── store/ # Estado global con Zustand +│ ├── alerts.ts # Lógica de alertas +│ ├── types.ts # Tipos TypeScript +│ └── utils.ts # Utilidades generales +└── public/ # Archivos estáticos +``` + +## 🎯 Funcionalidades Destacadas + +### Estado Global con Zustand +- Gestión reactiva del estado +- Persistencia automática +- Selectores optimizados +- Actualizaciones en tiempo real + +### Alertas Inteligentes +```typescript +// Tipos de alertas disponibles +- PAYMENT_DUE: Pago próximo a vencer +- BUDGET_WARNING: Límite de presupuesto alcanzado +- CARD_CLOSING: Fecha de cierre de tarjeta +- CARD_DUE: Vencimiento de tarjeta +- SAVINGS_GOAL: Meta de ahorro alcanzada +- UNUSUAL_SPENDING: Gasto inusual detectado +``` + +### Categorización Automática +- **Deudas Fijas**: vivienda, servicios, suscripciones +- **Deudas Variables**: compras, comida, entretenimiento, salud +- **Pagos con Cuotas**: seguimiento de instalaciones + +## 📊 Métricas y Visualizaciones + +- Gráficos de gastos por categoría +- Tendencias de mes a mes +- Progreso de metas de ahorro +- Distribución de deuda total + +## 🔐 Seguridad + +- Validación de datos con TypeScript +- Sanitización de inputs +- Sin almacenamiento de datos sensibles +- Ejecución completamente en el cliente + +## 📄 Licencia + +ISC + +## 👨‍💻 Autor + +Desarrollado por **renato97** + +--- + +⭐ **¿Te gusta el proyecto?** ¡No olvides darle una estrella en Gitea! diff --git a/app/alerts/page.tsx b/app/alerts/page.tsx new file mode 100644 index 0000000..c8c2fd5 --- /dev/null +++ b/app/alerts/page.tsx @@ -0,0 +1,58 @@ +'use client' + +import { Sidebar, Header, MobileNav } from '@/components/layout' +import { AlertPanel, useAlerts } from '@/components/alerts' +import { useSidebar } from '@/app/providers' +import { RefreshCw } from 'lucide-react' + +export default function AlertsPage() { + const { isOpen, toggle, close } = useSidebar() + const { regenerateAlerts, dismissAll, unreadCount } = useAlerts() + + const handleRegenerateAlerts = () => { + regenerateAlerts() + } + + const handleDismissAll = () => { + dismissAll() + } + + return ( +
+ + +
+
+ +
+
+ {/* Action Buttons */} +
+ + + +
+ + {/* Alert Panel */} +
+ +
+
+
+ + +
+
+ ) +} diff --git a/app/budget/page.tsx b/app/budget/page.tsx new file mode 100644 index 0000000..11fa1b8 --- /dev/null +++ b/app/budget/page.tsx @@ -0,0 +1,27 @@ +'use client' + +import { Sidebar, Header, MobileNav } from '@/components/layout' +import { BudgetSection } from '@/components/budget' +import { useSidebar } from '@/app/providers' +import { useAlerts } from '@/components/alerts' + +export default function BudgetPage() { + const { isOpen, close, toggle } = useSidebar() + const { unreadCount } = useAlerts() + + return ( +
+ + +
+
+ +
+ +
+ + +
+
+ ) +} diff --git a/app/cards/page.tsx b/app/cards/page.tsx new file mode 100644 index 0000000..489ddfa --- /dev/null +++ b/app/cards/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Sidebar, Header, MobileNav } from '@/components/layout'; +import { CardSection } from '@/components/cards'; +import { useSidebar } from '@/app/providers'; +import { useAlerts } from '@/components/alerts'; + +export default function CardsPage() { + const { isOpen, toggle, close } = useSidebar(); + const { unreadCount } = useAlerts(); + + return ( +
+ + +
+
+ +
+ +
+
+ + +
+ ); +} diff --git a/app/debts/page.tsx b/app/debts/page.tsx new file mode 100644 index 0000000..270b5f9 --- /dev/null +++ b/app/debts/page.tsx @@ -0,0 +1,32 @@ +'use client' + +import { Sidebar, Header, MobileNav } from '@/components/layout' +import { DebtSection } from '@/components/debts' +import { useSidebar } from '@/app/providers' +import { useAlerts } from '@/components/alerts' + +export default function DebtsPage() { + const { isOpen, close, open } = useSidebar() + const { unreadCount } = useAlerts() + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Header */} +
+ + {/* Page content */} +
+ +
+
+ + {/* Mobile Navigation */} + +
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..e9fefe1 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,332 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + /* Base Colors - Dark Theme (slate/emerald) */ + --background: 222 47% 11%; + --foreground: 210 40% 98%; + + /* Card Colors */ + --card: 217 33% 17%; + --card-foreground: 210 40% 98%; + + /* Popover Colors */ + --popover: 217 33% 17%; + --popover-foreground: 210 40% 98%; + + /* Primary - Emerald */ + --primary: 160 84% 39%; + --primary-foreground: 210 40% 98%; + + /* Secondary */ + --secondary: 217 33% 17%; + --secondary-foreground: 210 40% 98%; + + /* Muted */ + --muted: 217 33% 17%; + --muted-foreground: 215 20% 65%; + + /* Accent */ + --accent: 217 33% 17%; + --accent-foreground: 210 40% 98%; + + /* Destructive */ + --destructive: 0 84% 60%; + --destructive-foreground: 210 40% 98%; + + /* Border & Input */ + --border: 215 28% 25%; + --input: 215 28% 25%; + + /* Ring - Emerald */ + --ring: 160 84% 39%; + + /* Radius */ + --radius: 0.5rem; +} + +@theme inline { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +/* Base Styles */ +html { + scroll-behavior: smooth; +} + +body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-feature-settings: "rlig" 1, "calt" 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Input Styles */ +input, +textarea, +select { + background-color: hsl(var(--card)); + border-color: hsl(var(--border)); + color: hsl(var(--foreground)); +} + +input::placeholder, +textarea::placeholder { + color: hsl(var(--muted-foreground)); +} + +/* Selection */ +::selection { + background-color: hsl(160 84% 39% / 0.3); + color: hsl(var(--foreground)); +} + +/* Focus Ring */ +:focus-visible { + outline: none; + ring: 2px; + ring-color: hsl(var(--ring)); + ring-offset: 2px; + ring-offset-color: hsl(var(--background)); +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: hsl(var(--background)); +} + +::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); +} + +/* Firefox Scrollbar */ +* { + scrollbar-width: thin; + scrollbar-color: hsl(var(--border)) hsl(var(--background)); +} + +/* Utility Classes */ +.gradient-text { + background: linear-gradient(135deg, hsl(160 84% 39%) 0%, hsl(168 76% 42%) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.glass-card { + background: hsl(var(--card) / 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid hsl(var(--border) / 0.5); +} + +.text-balance { + text-wrap: balance; +} + +/* Loading States */ +.skeleton { + background: linear-gradient( + 90deg, + hsl(var(--muted)) 25%, + hsl(var(--muted-foreground) / 0.3) 50%, + hsl(var(--muted)) 75% + ); + background-size: 200% 100%; + animation: skeleton-pulse 1.5s ease-in-out infinite; + border-radius: var(--radius); +} + +@keyframes skeleton-pulse { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid hsl(var(--border)); + border-top-color: hsl(var(--primary)); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner-sm { + width: 16px; + height: 16px; + border-width: 2px; +} + +.spinner-lg { + width: 32px; + height: 32px; + border-width: 3px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse-slow { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes bounce-slight { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} + +/* Animation Utility Classes */ +.animate-fade-in { + animation: fadeIn 0.3s ease-out forwards; +} + +.animate-fade-in-up { + animation: fadeInUp 0.3s ease-out forwards; +} + +.animate-fade-in-down { + animation: fadeInDown 0.3s ease-out forwards; +} + +.animate-slide-in-left { + animation: slideInLeft 0.3s ease-out forwards; +} + +.animate-slide-in-right { + animation: slideInRight 0.3s ease-out forwards; +} + +.animate-pulse-slow { + animation: pulse-slow 2s ease-in-out infinite; +} + +.animate-bounce-slight { + animation: bounce-slight 1s ease-in-out infinite; +} + +/* Stagger Animation Delays */ +.stagger-1 { animation-delay: 0.05s; } +.stagger-2 { animation-delay: 0.1s; } +.stagger-3 { animation-delay: 0.15s; } +.stagger-4 { animation-delay: 0.2s; } +.stagger-5 { animation-delay: 0.25s; } +.stagger-6 { animation-delay: 0.3s; } +.stagger-7 { animation-delay: 0.35s; } +.stagger-8 { animation-delay: 0.4s; } + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..c523bc8 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { Providers } from "./providers"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", + display: "swap", +}); + +export const metadata: Metadata = { + title: "Finanzas Personales", + description: "Gestiona tus finanzas personales de forma inteligente", + keywords: ["finanzas", "presupuesto", "gastos", "ingresos", "ahorro"], +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..cba8300 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Sidebar, Header, MobileNav } from '@/components/layout' +import { SummarySection, QuickActions, RecentActivity } from '@/components/dashboard' +import { useSidebar } from '@/app/providers' +import { useFinanzasStore } from '@/lib/store' +import { AlertBanner, useAlerts } from '@/components/alerts' +import { AddDebtModal } from '@/components/modals/AddDebtModal' +import { AddCardModal } from '@/components/modals/AddCardModal' +import { AddPaymentModal } from '@/components/modals/AddPaymentModal' + +export default function Home() { + // Sidebar control + const sidebar = useSidebar() + + // Datos del store + const markAlertAsRead = useFinanzasStore((state) => state.markAlertAsRead) + const deleteAlert = useFinanzasStore((state) => state.deleteAlert) + + // Alertas + const { unreadAlerts, unreadCount, regenerateAlerts } = useAlerts() + + // Estados locales para modales + const [isAddDebtModalOpen, setIsAddDebtModalOpen] = useState(false) + const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false) + const [isAddPaymentModalOpen, setIsAddPaymentModalOpen] = useState(false) + + // Efecto para regenerar alertas al cargar la página + useEffect(() => { + regenerateAlerts() + }, [regenerateAlerts]) + + // Efecto para manejar resize de ventana + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 1024) { + sidebar.open() + } else { + sidebar.close() + } + } + + // Estado inicial + handleResize() + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [sidebar]) + + // Handlers para modales + const handleAddDebt = () => { + setIsAddDebtModalOpen(true) + } + + const handleAddCard = () => { + setIsAddCardModalOpen(true) + } + + const handleAddPayment = () => { + setIsAddPaymentModalOpen(true) + } + + // Primeras 3 alertas no leídas + const topAlerts = unreadAlerts.slice(0, 3) + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Header */} +
+ + {/* Main content area */} +
+
+ {/* Alertas destacadas */} + {topAlerts.length > 0 && ( +
+ {topAlerts.map((alert) => ( + deleteAlert(alert.id)} + onMarkRead={() => markAlertAsRead(alert.id)} + /> + ))} +
+ )} + + {/* Sección de resumen */} + + + {/* Acciones rápidas */} + + + {/* Actividad reciente */} + +
+
+
+ + {/* Mobile navigation */} + + + {/* Modales */} + setIsAddDebtModalOpen(false)} + /> + + setIsAddCardModalOpen(false)} + /> + + setIsAddPaymentModalOpen(false)} + /> +
+ ) +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..da0fe27 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { createContext, useContext, useState, ReactNode } from "react"; + +interface SidebarContextType { + isOpen: boolean; + toggle: () => void; + close: () => void; + open: () => void; +} + +const SidebarContext = createContext(undefined); + +export function Providers({ children }: { children: ReactNode }) { + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + + const toggleSidebar = () => setIsSidebarOpen((prev) => !prev); + const closeSidebar = () => setIsSidebarOpen(false); + const openSidebar = () => setIsSidebarOpen(true); + + return ( + + {children} + + ); +} + +export function useSidebar() { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error("useSidebar must be used within a Providers"); + } + return context; +} diff --git a/components/alerts/AlertBadge.tsx b/components/alerts/AlertBadge.tsx new file mode 100644 index 0000000..88e68ff --- /dev/null +++ b/components/alerts/AlertBadge.tsx @@ -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 ( + + ) + } + + return ( + + {count > 99 ? '99+' : count} + + ) +} diff --git a/components/alerts/AlertBanner.tsx b/components/alerts/AlertBanner.tsx new file mode 100644 index 0000000..6bbc6e6 --- /dev/null +++ b/components/alerts/AlertBanner.tsx @@ -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 ( +
+
+
+ +
+ +
+

{alert.title}

+

{alert.message}

+
+ +
+ {!alert.isRead && ( + + )} + + +
+
+
+ ) +} diff --git a/components/alerts/AlertIcon.tsx b/components/alerts/AlertIcon.tsx new file mode 100644 index 0000000..ad02c46 --- /dev/null +++ b/components/alerts/AlertIcon.tsx @@ -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 = { + 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 +} diff --git a/components/alerts/AlertItem.tsx b/components/alerts/AlertItem.tsx new file mode 100644 index 0000000..75fafe5 --- /dev/null +++ b/components/alerts/AlertItem.tsx @@ -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 ( +
setShowActions(true)} + onMouseLeave={() => setShowActions(false)} + role="listitem" + > + {/* Unread indicator */} + {!alert.isRead && ( + + )} + + {/* Icon */} +
+ +
+ + {/* Content */} +
+

+ {alert.title} +

+

{getRelativeTime(alert.date)}

+
+ + {/* Actions */} +
+ {!alert.isRead && ( + + )} + + +
+
+ ) +} diff --git a/components/alerts/AlertPanel.tsx b/components/alerts/AlertPanel.tsx new file mode 100644 index 0000000..dd021e9 --- /dev/null +++ b/components/alerts/AlertPanel.tsx @@ -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('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 ( +
+ {/* Header */} +
+
+
+ + {unreadCount > 0 && } +
+

Alertas

+ {unreadCount > 0 && ( + ({unreadCount}) + )} +
+ +
+ {unreadCount > 0 && ( + + )} + + {alerts.length > 0 && ( + + )} +
+
+ + {/* Tabs */} + {alerts.length > 0 && ( +
+ + +
+ )} + + {/* Alert List */} +
+ {displayedAlerts.length === 0 ? ( +
+
+ +
+

+ {activeTab === 'unread' + ? 'No tienes alertas sin leer' + : 'No tienes alertas'} +

+

+ Las alertas aparecerán cuando haya pagos próximos o eventos importantes +

+
+ ) : ( +
+ {displayedAlerts.map((alert) => ( + markAlertAsRead(alert.id)} + onDelete={() => deleteAlert(alert.id)} + /> + ))} +
+ )} +
+
+ ) +} diff --git a/components/alerts/index.ts b/components/alerts/index.ts new file mode 100644 index 0000000..c83a77a --- /dev/null +++ b/components/alerts/index.ts @@ -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' diff --git a/components/alerts/useAlerts.ts b/components/alerts/useAlerts.ts new file mode 100644 index 0000000..5749a3f --- /dev/null +++ b/components/alerts/useAlerts.ts @@ -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, + } +} diff --git a/components/budget/BudgetCard.tsx b/components/budget/BudgetCard.tsx new file mode 100644 index 0000000..cb5d312 --- /dev/null +++ b/components/budget/BudgetCard.tsx @@ -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 + case 'down': + return + default: + return + } + } + + const getTrendText = () => { + switch (trend) { + case 'up': + return Positivo + case 'down': + return Negativo + default: + return Neutral + } + } + + const textColor = color || 'text-white' + + return ( +
+
+

{label}

+ {getTrendIcon()} +
+

+ {formatCurrency(amount)} +

+
{getTrendText()}
+
+ ) +} diff --git a/components/budget/BudgetForm.tsx b/components/budget/BudgetForm.tsx new file mode 100644 index 0000000..68ed41c --- /dev/null +++ b/components/budget/BudgetForm.tsx @@ -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>({}) + + const validate = (): boolean => { + const newErrors: Record = {} + + 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 = ( + 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 ( +
+
+
+ + + {errors.month &&

{errors.month}

} +
+ +
+ + 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 &&

{errors.year}

} +
+
+ +
+ + 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 &&

{errors.totalIncome}

} +
+ +
+ + 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 &&

{errors.savingsGoal}

} + {formData.totalIncome > 0 && ( +

+ Disponible para gastos: {((formData.totalIncome - formData.savingsGoal) / formData.totalIncome * 100).toFixed(0)}% +

+ )} +
+ +
+ + +
+
+ ) +} diff --git a/components/budget/BudgetProgress.tsx b/components/budget/BudgetProgress.tsx new file mode 100644 index 0000000..9bf0d20 --- /dev/null +++ b/components/budget/BudgetProgress.tsx @@ -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 ( +
+
+ {label} + + {formatCurrency(current)} / {formatCurrency(max)} + +
+
+
+
+
+ {percentage.toFixed(0)}% usado + {percentage >= 100 && ( + Límite alcanzado + )} +
+
+ ) +} diff --git a/components/budget/BudgetRing.tsx b/components/budget/BudgetRing.tsx new file mode 100644 index 0000000..0166bea --- /dev/null +++ b/components/budget/BudgetRing.tsx @@ -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 ( +
+
+ + {/* Background circle */} + + {/* Progress circle */} + + + {/* Center content */} +
+ + {percentage.toFixed(0)}% + + usado +
+
+ + {/* Stats below */} +
+

{label}

+

+ {formatCurrency(spent)} / {formatCurrency(total)} +

+

+ {formatCurrency(remaining)} disponible +

+
+
+ ) +} diff --git a/components/budget/BudgetSection.tsx b/components/budget/BudgetSection.tsx new file mode 100644 index 0000000..677be15 --- /dev/null +++ b/components/budget/BudgetSection.tsx @@ -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 ( +
+
+
+
+

Presupuesto Mensual

+

+ {getMonthName(currentMonth)} {currentYear} +

+
+
+ +
+ +

+ No hay presupuesto para este mes +

+

+ Crea un presupuesto para comenzar a gestionar tus finanzas +

+ +
+ + {isModalOpen && ( +
+
+
+
+

+ Nuevo presupuesto +

+ +
+
+
+ )} +
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+

Presupuesto Mensual

+

+ {getMonthName(currentMonth)} {currentYear} +

+
+ +
+ + {/* Summary Cards */} +
+ + + availableForExpenses ? 'down' : 'neutral'} + color={totalSpent > availableForExpenses ? 'text-red-400' : 'text-amber-400'} + /> + 0 ? 'up' : 'down'} + color={remaining > 0 ? 'text-emerald-400' : 'text-red-400'} + /> +
+ + {/* Budget Ring and Breakdown */} +
+ {/* Ring */} +
+ +
+ + {/* Breakdown */} +
+

Desglose de gastos

+
+ + + +
+
+
+ + {/* Projection */} +
+
+
availableForExpenses ? 'bg-red-500/10' : 'bg-emerald-500/10' + )}> + {projectedEndOfMonth > availableForExpenses ? ( + + ) : ( + + )} +
+
+

Proyección

+

+ A tu ritmo actual de gasto ({formatCurrency(dailySpendRate)}/día), + {projectedEndOfMonth > availableForExpenses ? ( + + {' '}terminarás el mes con un déficit de {formatCurrency(projectedEndOfMonth - availableForExpenses)}. + + ) : ( + + {' '}terminarás el mes con un superávit de {formatCurrency(availableForExpenses - projectedEndOfMonth)}. + + )} +

+

+ Quedan {daysRemaining} días en el mes +

+
+
+
+
+ + {/* Modal */} + {isModalOpen && ( +
+
+
+
+

+ {isEditing ? 'Editar presupuesto' : 'Nuevo presupuesto'} +

+ +
+
+
+ )} +
+ ) +} diff --git a/components/budget/index.ts b/components/budget/index.ts new file mode 100644 index 0000000..1bc4604 --- /dev/null +++ b/components/budget/index.ts @@ -0,0 +1,5 @@ +export { BudgetForm } from './BudgetForm'; +export { BudgetRing } from './BudgetRing'; +export { BudgetProgress } from './BudgetProgress'; +export { BudgetCard } from './BudgetCard'; +export { BudgetSection } from './BudgetSection'; diff --git a/components/cards/CardPaymentForm.tsx b/components/cards/CardPaymentForm.tsx new file mode 100644 index 0000000..1b0123b --- /dev/null +++ b/components/cards/CardPaymentForm.tsx @@ -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({ + description: '', + amount: 0, + date: today, + installments: undefined, + }) + + const [hasInstallments, setHasInstallments] = useState(false) + const [errors, setErrors] = useState>({}) + + const validateForm = (): boolean => { + const newErrors: Record = {} + + 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 ( +
+
+

Registrar Pago

+ +
+ +
+ {/* Description */} +
+ + 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 && ( +

{errors.description}

+ )} +
+ +
+ {/* Amount */} +
+ + 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 && ( +

{errors.amount}

+ )} +
+ + {/* Date */} +
+ + 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 &&

{errors.date}

} +
+
+ + {/* Installments Toggle */} +
+ { + 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" + /> + +
+ + {/* Installments Fields */} + {hasInstallments && ( +
+
+ + + 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 && ( +

{errors.installmentCurrent}

+ )} +
+ +
+ + + 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 && ( +

{errors.installmentTotal}

+ )} +
+
+ )} + {errors.installments && ( +

{errors.installments}

+ )} +
+ + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/components/cards/CardSection.tsx b/components/cards/CardSection.tsx new file mode 100644 index 0000000..bc56795 --- /dev/null +++ b/components/cards/CardSection.tsx @@ -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(null) + const [selectedCardId, setSelectedCardId] = useState('') + 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) => { + 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 ( +
+ {/* Header */} +
+
+
+ +
+
+

Tarjetas de Crédito

+

+ {getMonthName(currentMonth)} {currentYear} +

+
+
+ +
+ + {/* Cards Grid */} + {creditCards.length === 0 ? ( +
+ +

+ No tienes tarjetas registradas +

+

+ Agrega tu primera tarjeta para comenzar a gestionar tus pagos +

+ +
+ ) : ( +
+ {creditCards.map((card) => ( + handleEditCard(card)} + onDelete={() => handleDeleteCard(card.id)} + /> + ))} +
+ )} + + {/* Card Form Modal */} + {showCardForm && ( +
+
+ { + setShowCardForm(false) + setEditingCard(null) + }} + /> +
+
+ )} + + {/* Payment Section */} + {creditCards.length > 0 && ( +
+ {/* Register Payment */} +
+
+
+ +
+

Registrar Pago

+
+ + {/* Card Selector */} +
+ +
+ {creditCards.map((card) => ( + { + setSelectedCardId(card.id) + setShowPaymentForm(true) + }} + /> + ))} +
+
+ + {/* Payment Form */} + {showPaymentForm && selectedCardId && ( + { + setShowPaymentForm(false) + setSelectedCardId('') + }} + /> + )} +
+ + {/* Recent Payments */} +
+
+
+ +
+
+

Pagos del Mes

+

+ Total: {formatCurrency( + currentMonthPayments.reduce((sum, p) => sum + p.amount, 0) + )} +

+
+
+ + {currentMonthPayments.length === 0 ? ( +
+

+ No hay pagos registrados este mes +

+
+ ) : ( +
+ {currentMonthPayments + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .map((payment) => { + const card = getCardById(payment.cardId) + return ( +
+
+ {card && ( +
+ )} +
+

+ {payment.description} +

+

+ {card?.name} • {formatShortDate(payment.date)} + {payment.installments && ( + + Cuota {payment.installments.current}/{payment.installments.total} + + )} +

+
+
+
+ + {formatCurrency(payment.amount)} + + +
+
+ ) + })} +
+ )} + + {/* Summary by Card */} + {currentMonthPayments.length > 0 && ( +
+

+ Resumen por tarjeta +

+
+ {creditCards.map((card) => { + const total = getCardTotalPayments(card.id) + if (total === 0) return null + return ( +
+
+
+ {card.name} +
+ + {formatCurrency(total)} + +
+ ) + })} +
+
+ )} +
+
+ )} +
+ ) +} diff --git a/components/cards/CreditCardForm.tsx b/components/cards/CreditCardForm.tsx new file mode 100644 index 0000000..c38b8be --- /dev/null +++ b/components/cards/CreditCardForm.tsx @@ -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 + onSubmit: (data: Omit) => 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>({}) + + const validateForm = (): boolean => { + const newErrors: Record = {} + + 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 ( +
+
+

+ {initialData ? 'Editar Tarjeta' : 'Nueva Tarjeta'} +

+ +
+ +
+ {/* Card Name */} +
+ + 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 &&

{errors.name}

} +
+ + {/* Last 4 Digits */} +
+ + 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 && ( +

{errors.lastFourDigits}

+ )} +
+ + {/* Color Picker */} +
+ +
+ updateField('color', e.target.value)} + className="h-10 w-20 cursor-pointer rounded-lg border border-slate-600 bg-slate-700" + /> + {formData.color} +
+
+ + {/* Closing Day */} +
+ + 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 && ( +

{errors.closingDay}

+ )} +
+ + {/* Due Day */} +
+ + 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 &&

{errors.dueDay}

} +
+ + {/* Credit Limit */} +
+ + 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 && ( +

{errors.creditLimit}

+ )} +
+ + {/* Current Balance */} +
+ + 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 && ( +

{errors.currentBalance}

+ )} +
+
+ + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/components/cards/CreditCardWidget.tsx b/components/cards/CreditCardWidget.tsx new file mode 100644 index 0000000..b596e51 --- /dev/null +++ b/components/cards/CreditCardWidget.tsx @@ -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 ( +
+ {/* Decorative circles */} +
+
+ + {/* Header with card name and actions */} +
+

{card.name}

+
+ + +
+
+ + {/* Card number */} +
+

+ **** **** **** {card.lastFourDigits} +

+
+ + {/* Balance */} +
+

Balance actual

+

{formatCurrency(card.currentBalance)}

+
+ + {/* Utilization badge */} +
+
+
+ + {utilization.toFixed(0)}% usado + +
+ + de {formatCurrency(card.creditLimit)} + +
+ + {/* Footer with closing and due dates */} +
+
+ Cierre + + {card.closingDay} ({daysUntilClosing === 0 ? 'hoy' : daysUntilClosing > 0 ? `en ${daysUntilClosing} días` : `hace ${Math.abs(daysUntilClosing)} días`}) + +
+
+ Vencimiento + + {card.dueDay} ({daysUntilDue === 0 ? 'hoy' : daysUntilDue > 0 ? `en ${daysUntilDue} días` : `hace ${Math.abs(daysUntilDue)} días`}) + +
+
+
+ ) +} + +/** + * 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')}` +} diff --git a/components/cards/MiniCard.tsx b/components/cards/MiniCard.tsx new file mode 100644 index 0000000..dc83b98 --- /dev/null +++ b/components/cards/MiniCard.tsx @@ -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 ( + + ) +} diff --git a/components/cards/index.ts b/components/cards/index.ts new file mode 100644 index 0000000..2c5fca8 --- /dev/null +++ b/components/cards/index.ts @@ -0,0 +1,5 @@ +export { CreditCardWidget } from './CreditCardWidget' +export { CreditCardForm } from './CreditCardForm' +export { CardPaymentForm } from './CardPaymentForm' +export { MiniCard } from './MiniCard' +export { CardSection } from './CardSection' diff --git a/components/dashboard/DashboardHeader.tsx b/components/dashboard/DashboardHeader.tsx new file mode 100644 index 0000000..387866b --- /dev/null +++ b/components/dashboard/DashboardHeader.tsx @@ -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 ( +
+
+

+ Dashboard + + {monthName} {currentYear} + +

+

+ {formattedDate} +

+
+ + {onRefresh && ( + + )} +
+ ) +} diff --git a/components/dashboard/ExpenseChart.tsx b/components/dashboard/ExpenseChart.tsx new file mode 100644 index 0000000..4be8c83 --- /dev/null +++ b/components/dashboard/ExpenseChart.tsx @@ -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 = { + // 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 = { + 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() + + // 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 ( +
+

No hay gastos pendientes

+
+ ) + } + + return ( +
+

+ Distribución de Gastos +

+ +
+ {/* Gráfico de dona */} +
+ + + + {data.map((entry, index) => ( + + ))} + + + typeof value === 'number' ? formatCurrency(value) : value + } + contentStyle={{ + backgroundColor: '#1e293b', + border: '1px solid #334155', + borderRadius: '8px', + color: '#fff', + }} + /> + + +
+ + {/* Leyenda */} +
+ {data.map((item) => { + const percentage = total > 0 ? (item.value / total) * 100 : 0 + return ( +
+
+
+
+ + {item.name} + + + {percentage.toFixed(1)}% + +
+

+ {formatCurrency(item.value)} +

+
+
+ ) + })} + + {/* Total */} +
+
+ Total + + {formatCurrency(total)} + +
+
+
+
+
+ ) +} diff --git a/components/dashboard/MetricCard.tsx b/components/dashboard/MetricCard.tsx new file mode 100644 index 0000000..bc79b7f --- /dev/null +++ b/components/dashboard/MetricCard.tsx @@ -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 ( +
+ {/* Icono en esquina superior derecha */} +
+ +
+ + {/* Contenido */} +
+ {/* Título */} +

{title}

+ + {/* Monto */} +

+ {formatCurrency(amount)} +

+ + {/* Subtítulo */} + {subtitle && ( +

{subtitle}

+ )} + + {/* Indicador de tendencia */} + {trend && ( +
+ {trend.isPositive ? ( + <> + + + +{trend.value}% + + + ) : ( + <> + + + -{trend.value}% + + + )} + vs mes anterior +
+ )} +
+
+ ) +} diff --git a/components/dashboard/QuickActions.tsx b/components/dashboard/QuickActions.tsx new file mode 100644 index 0000000..a2cd3a7 --- /dev/null +++ b/components/dashboard/QuickActions.tsx @@ -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 ( +
+ {actions.map((action) => { + const Icon = action.icon + return ( + + ) + })} +
+ ) +} diff --git a/components/dashboard/RecentActivity.tsx b/components/dashboard/RecentActivity.tsx new file mode 100644 index 0000000..993f1ec --- /dev/null +++ b/components/dashboard/RecentActivity.tsx @@ -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 ( +
+

+ Actividad Reciente +

+
+

No hay actividad reciente

+
+
+ ) + } + + return ( +
+

+ Actividad Reciente +

+ +
+ {sortedActivities.map((activity) => { + const config = activityConfig[activity.type] + const Icon = config.icon + + return ( +
+ {/* Icono */} +
+ +
+ + {/* Contenido */} +
+
+

+ {activity.title} +

+ + {activity.type === 'card_payment' ? '+' : '-'} + {formatCurrency(activity.amount)} + +
+ +
+ + {config.label} + + + {formatShortDate(activity.date)} + {activity.description && ( + <> + + {activity.description} + + )} +
+
+
+ ) + })} +
+
+ ) +} diff --git a/components/dashboard/SummarySection.tsx b/components/dashboard/SummarySection.tsx new file mode 100644 index 0000000..bbd275c --- /dev/null +++ b/components/dashboard/SummarySection.tsx @@ -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 ( +
+ {/* Grid de métricas */} +
+ !d.isPaid).length + variableDebts.filter((d) => !d.isPaid).length} pagos pendientes`} + icon={Wallet} + color="text-rose-400" + /> + + + + + + 0 + ? `${((projectedSavings / savingsGoal) * 100).toFixed(0)}% de la meta` + : 'Sin meta definida' + } + icon={PiggyBank} + color="text-violet-400" + /> +
+ + {/* Gráfico y alertas */} +
+ {/* Gráfico de distribución */} + + + {/* Alertas destacadas */} +
+

+ Alertas Destacadas +

+ + {unreadAlerts.length === 0 ? ( +
+

No hay alertas pendientes

+
+ ) : ( +
+ {unreadAlerts.map((alert) => ( +
+ +
+

{alert.title}

+

{alert.message}

+
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/components/dashboard/index.ts b/components/dashboard/index.ts new file mode 100644 index 0000000..8d1e0ca --- /dev/null +++ b/components/dashboard/index.ts @@ -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' diff --git a/components/debts/DebtCard.tsx b/components/debts/DebtCard.tsx new file mode 100644 index 0000000..2a1ce60 --- /dev/null +++ b/components/debts/DebtCard.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( +
+
+ {/* Checkbox */} + + + {/* Content */} +
+
+
+

+ {debt.name} +

+

{getDueInfo()}

+
+ + {formatCurrency(debt.amount)} + +
+ +
+ + {categoryLabels[debt.category] || debt.category} + + + {isFixed && (debt as FixedDebt).isAutoDebit && ( + + Débito automático + + )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/components/debts/DebtSection.tsx b/components/debts/DebtSection.tsx new file mode 100644 index 0000000..921bcb8 --- /dev/null +++ b/components/debts/DebtSection.tsx @@ -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('fixed') + const [isModalOpen, setIsModalOpen] = useState(false) + const [editingDebt, setEditingDebt] = useState(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) => { + if (editingDebt?.id) { + updateFixedDebt(editingDebt.id, data) + } else { + addFixedDebt({ ...data, isPaid: false }) + } + handleCloseModal() + } + + const handleSubmitVariable = (data: Omit) => { + 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 ( +
+
+ {/* Header */} +
+
+

Deudas

+

+ Gestiona tus gastos fijos y variables +

+
+ +
+ + {/* Summary Cards */} +
+
+

Total pendiente

+

+ {formatCurrency(totalUnpaid)} +

+
+
+

Pagadas

+

+ {paidCount} +

+
+
+

Pendientes

+

+ {unpaidCount} +

+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Debt List */} +
+ {currentDebts.length === 0 ? ( +
+ +

+ No hay deudas {activeTab === 'fixed' ? 'fijas' : 'variables'} +

+

+ Haz clic en "Agregar" para crear una nueva deuda +

+
+ ) : ( + currentDebts.map((debt) => ( + handleTogglePaid(debt)} + onEdit={() => handleEdit(debt)} + onDelete={() => handleDelete(debt)} + /> + )) + )} +
+
+ + {/* Modal */} + {isModalOpen && ( +
+
+
+
+

+ {editingDebt + ? 'Editar deuda' + : activeTab === 'fixed' + ? 'Nueva deuda fija' + : 'Nueva deuda variable'} +

+ {activeTab === 'fixed' ? ( + | undefined} + onSubmit={handleSubmitFixed} + onCancel={handleCloseModal} + /> + ) : ( + | undefined} + onSubmit={handleSubmitVariable} + onCancel={handleCloseModal} + /> + )} +
+
+
+ )} +
+ ) +} diff --git a/components/debts/FixedDebtForm.tsx b/components/debts/FixedDebtForm.tsx new file mode 100644 index 0000000..a2be6ce --- /dev/null +++ b/components/debts/FixedDebtForm.tsx @@ -0,0 +1,212 @@ +'use client' + +import { useState } from 'react' +import { FixedDebt } from '@/lib/types' +import { cn } from '@/lib/utils' + +interface FixedDebtFormProps { + initialData?: Partial + onSubmit: (data: Omit) => 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>({}) + + const validate = (): boolean => { + const newErrors: Record = {} + + 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 = ( + 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 ( +
+
+ + 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 &&

{errors.name}

} +
+ +
+
+ + 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 &&

{errors.amount}

} +
+ +
+ + 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 &&

{errors.dueDay}

} +
+
+ +
+ + +
+ +
+ 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" + /> + +
+ +
+ +