commit 9c11f23af0724b571939646ad312e55de91f6603 Author: Renato97 Date: Tue Mar 31 01:23:33 2026 -0300 Initial commit - cleaned for CV diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eda8024 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# 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 + +# Security +server-settings.json +.env +auth-otp.json + +# Local dev +local_Caddyfile 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..78ad5c2 --- /dev/null +++ b/app/alerts/page.tsx @@ -0,0 +1,46 @@ +'use client' + +import { DashboardLayout } from '@/components/layout/DashboardLayout' +import { AlertPanel, useAlerts } from '@/components/alerts' +import { RefreshCw } from 'lucide-react' + +export default function AlertsPage() { + const { regenerateAlerts, dismissAll } = useAlerts() + + const handleRegenerateAlerts = () => { + regenerateAlerts() + } + + const handleDismissAll = () => { + dismissAll() + } + + return ( + +
+ {/* Action Buttons */} +
+ + + +
+ + {/* Alert Panel */} +
+ +
+
+
+ ) +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..8b0b94b --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from 'next/server'; +import { findUser, verifyPassword, createSession } from '@/lib/auth'; +import { generateOTP } from '@/lib/otp'; +import TelegramBot from 'node-telegram-bot-api'; + +const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; + +async function sendTelegramOTP(chatId: string, otp: string) { + if (!BOT_TOKEN) return false; + try { + const bot = new TelegramBot(BOT_TOKEN, { polling: false }); + await bot.sendMessage(chatId, `🔐 *Código de Acceso Finanzas*\n\nTu código es: \`${otp}\`\n\nSi no intentaste ingresar, ignora este mensaje.`, { parse_mode: 'Markdown' }); + return true; + } catch (e) { + console.error('Telegram send error:', e); + return false; + } +} + +export async function POST(req: Request) { + try { + const { username, password } = await req.json(); + const ip = req.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'; + + const user = findUser(username); + + if (!user || !(await verifyPassword(password, user.passwordHash))) { + return NextResponse.json({ error: 'Credenciales inválidas' }, { status: 401 }); + } + + // Check IP + const isKnownIp = user.knownIps.includes(ip); + + if (isKnownIp) { + // Login success directly + await createSession(user); + return NextResponse.json({ success: true, requireOtp: false }); + } else { + // Require OTP + const otp = generateOTP(user.username); + + const sent = await sendTelegramOTP(user.chatId, otp); + + if (!sent) { + return NextResponse.json({ error: 'No se pudo enviar el código OTP a Telegram' }, { status: 500 }); + } + + return NextResponse.json({ success: true, requireOtp: true }); + } + } catch (error) { + console.error('Login error:', error); + return NextResponse.json({ error: 'Error interno' }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..7f10f5f --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; +import { destroySession } from '@/lib/auth'; + +export async function POST() { + destroySession(); + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..ffb5c4f --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { saveUser, findUser, hashPassword, createSession } from '@/lib/auth'; +import { randomUUID } from 'crypto'; + +export async function POST(req: Request) { + try { + const { username, password, chatId } = await req.json(); + + if (!username || !password || !chatId) { + return NextResponse.json({ error: 'Faltan datos requeridos' }, { status: 400 }); + } + + if (findUser(username)) { + return NextResponse.json({ error: 'El usuario ya existe' }, { status: 409 }); + } + + // Hash password + const passwordHash = await hashPassword(password); + + // Get IP + const ip = req.headers.get('x-forwarded-for') || '127.0.0.1'; + + const newUser = { + id: randomUUID(), + username, + passwordHash, + chatId, + knownIps: [ip] // Register current IP as known initially + }; + + saveUser(newUser); + + // Auto login after register + await createSession(newUser); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Register error:', error); + return NextResponse.json({ error: 'Error interno del servidor' }, { status: 500 }); + } +} diff --git a/app/api/auth/verify-otp/route.ts b/app/api/auth/verify-otp/route.ts new file mode 100644 index 0000000..9194b67 --- /dev/null +++ b/app/api/auth/verify-otp/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { findUser, saveUser, createSession } from '@/lib/auth'; +import { verifyOTP } from '@/lib/otp'; + +export async function POST(req: Request) { + try { + const { username, otp } = await req.json(); + const ip = req.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown'; + + if (!verifyOTP(username, otp)) { + return NextResponse.json({ error: 'Código inválido o expirado' }, { status: 401 }); + } + + const user = findUser(username); + if (!user) { + return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 }); + } + + // Add IP to known list if not exists + if (!user.knownIps.includes(ip) && ip !== 'unknown') { + user.knownIps.push(ip); + saveUser(user); + } + + // Login success + await createSession(user); + + return NextResponse.json({ success: true }); + + } catch (error) { + console.error('OTP Verify error:', error); + return NextResponse.json({ error: 'Error interno' }, { status: 500 }); + } +} diff --git a/app/api/proxy/models/route.ts b/app/api/proxy/models/route.ts new file mode 100644 index 0000000..b22d4aa --- /dev/null +++ b/app/api/proxy/models/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' + +export async function POST(request: Request) { + try { + const { endpoint, token } = await request.json() + + if (!endpoint || !token) { + return NextResponse.json({ success: false, error: 'Faltan datos' }, { status: 400 }) + } + + // Try standard /v1/models endpoint + // If user provided "https://api.example.com/v1", we append "/models" + let targetUrl = endpoint + if (targetUrl.endsWith('/')) { + targetUrl = `${targetUrl}v1/models` + } else if (!targetUrl.endsWith('/models')) { + targetUrl = `${targetUrl}/v1/models` + } + + const response = await fetch(targetUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'x-api-key': token + } + }) + + if (!response.ok) { + const text = await response.text() + return NextResponse.json({ success: false, error: text }, { status: response.status }) + } + + const data = await response.json() + // Normalizing response: OpenAI/Anthropic usually return { data: [{ id: 'model-name' }, ...] } + + let models: string[] = [] + if (Array.isArray(data.data)) { + models = data.data.map((m: any) => m.id) + } else if (Array.isArray(data)) { + models = data.map((m: any) => m.id || m.model || m) + } + + return NextResponse.json({ success: true, models }) + + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message }, { status: 500 }) + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..2c55025 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import fs from 'fs' +import path from 'path' +import { AppSettings } from '@/lib/types' + +const SETTINGS_FILE = path.join(process.cwd(), 'server-settings.json') + +const DEFAULT_SETTINGS: AppSettings = { + telegram: { + botToken: '', + chatId: '', + }, + aiProviders: [], +} + +export async function GET() { + try { + if (!fs.existsSync(SETTINGS_FILE)) { + return NextResponse.json(DEFAULT_SETTINGS) + } + const data = fs.readFileSync(SETTINGS_FILE, 'utf8') + const settings = JSON.parse(data) + return NextResponse.json(settings) + } catch (error) { + console.error('Error reading settings:', error) + return NextResponse.json(DEFAULT_SETTINGS, { status: 500 }) + } +} + +export async function POST(request: Request) { + try { + const body = await request.json() + // Basic validation could go here + const settings: AppSettings = { + telegram: { + botToken: body.telegram?.botToken || '', + chatId: body.telegram?.chatId || '' + }, + aiProviders: Array.isArray(body.aiProviders) ? body.aiProviders : [] + } + + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)) + return NextResponse.json({ success: true, settings }) + } catch (error) { + console.error('Error saving settings:', error) + return NextResponse.json({ success: false, error: 'Failed to save settings' }, { status: 500 }) + } +} diff --git a/app/api/sync/route.ts b/app/api/sync/route.ts new file mode 100644 index 0000000..5f8ba96 --- /dev/null +++ b/app/api/sync/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { getDatabase, saveDatabase } from '@/lib/server-db'; +import { verifySession } from '@/lib/auth'; + +export async function GET() { + const session = await verifySession(); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const data = getDatabase(session.username); + return NextResponse.json(data); +} + +export async function POST(req: Request) { + const session = await verifySession(); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const body = await req.json(); + saveDatabase(session.username, body); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: 'Invalid Data' }, { status: 400 }); + } +} diff --git a/app/api/test/ai/route.ts b/app/api/test/ai/route.ts new file mode 100644 index 0000000..54445e3 --- /dev/null +++ b/app/api/test/ai/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server' + +export async function POST(request: Request) { + try { + const { endpoint, token, model } = await request.json() + + if (!endpoint || !token) { + return NextResponse.json( + { success: false, error: 'Faltan credenciales (Endpoint o Token)' }, + { status: 400 } + ) + } + + // Prepare target URL + let targetUrl = endpoint + if (!targetUrl.endsWith('/messages') && !targetUrl.endsWith('/chat/completions')) { + targetUrl = targetUrl.endsWith('/') ? `${targetUrl}v1/messages` : `${targetUrl}/v1/messages` + } + + const start = Date.now() + + // Payload for Anthropic /v1/messages + const body = { + model: model || "gpt-3.5-turbo", // Fallback if no model selected + messages: [{ role: "user", content: "Ping" }], + max_tokens: 10 + } + + const response = await fetch(targetUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': token, + 'anthropic-version': '2023-06-01', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(body) + }) + + const duration = Date.now() - start + + if (!response.ok) { + const text = await response.text() + return NextResponse.json( + { success: false, error: `Error ${response.status}: ${text.slice(0, 100)}` }, + { status: response.status } + ) + } + + return NextResponse.json({ success: true, latency: duration }) + + } catch (error: any) { + console.error('AI Test Error:', error) + return NextResponse.json( + { success: false, error: error.message || 'Error de conexión' }, + { status: 500 } + ) + } +} diff --git a/app/api/test/telegram/route.ts b/app/api/test/telegram/route.ts new file mode 100644 index 0000000..ad5463f --- /dev/null +++ b/app/api/test/telegram/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' + +export async function POST(request: Request) { + try { + const { botToken, chatId } = await request.json() + + if (!botToken || !chatId) { + return NextResponse.json( + { success: false, error: 'Faltan credenciales (Token o Chat ID)' }, + { status: 400 } + ) + } + + const message = "🤖 *Prueba de Conexión*\n\n¡Hola! Si lees esto, tu bot de Finanzas está correctamente configurado. 🚀" + + const url = `https://api.telegram.org/bot${botToken}/sendMessage` + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + parse_mode: 'Markdown' + }) + }) + + const data = await response.json() + + if (!data.ok) { + return NextResponse.json( + { success: false, error: data.description || 'Error desconocido de Telegram' }, + { status: 500 } + ) + } + + return NextResponse.json({ success: true, data }) + + } catch (error: any) { + console.error('Telegram Test Error:', error) + return NextResponse.json( + { success: false, error: error.message || 'Error interno del servidor' }, + { status: 500 } + ) + } +} diff --git a/app/budget/page.tsx b/app/budget/page.tsx new file mode 100644 index 0000000..f4824e3 --- /dev/null +++ b/app/budget/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import { DashboardLayout } from '@/components/layout/DashboardLayout' +import { BudgetSection } from '@/components/budget' + +export default function BudgetPage() { + return ( + + + + ) +} diff --git a/app/cards/page.tsx b/app/cards/page.tsx new file mode 100644 index 0000000..c74ffca --- /dev/null +++ b/app/cards/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { DashboardLayout } from '@/components/layout/DashboardLayout'; +import { CardSection } from '@/components/cards'; + +export default function CardsPage() { + return ( + + + + ); +} diff --git a/app/debts/page.tsx b/app/debts/page.tsx new file mode 100644 index 0000000..87f3f27 --- /dev/null +++ b/app/debts/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import { DashboardLayout } from '@/components/layout/DashboardLayout' +import { DebtSection } from '@/components/debts' + +export default function DebtsPage() { + return ( + + + + ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..6cd5268 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,308 @@ +@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; +} + + + +/* 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/incomes/page.tsx b/app/incomes/page.tsx new file mode 100644 index 0000000..40392be --- /dev/null +++ b/app/incomes/page.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useState } from 'react' +import { useFinanzasStore } from '@/lib/store' +import { Plus, Trash2, TrendingUp, DollarSign } from 'lucide-react' +import { format } from 'date-fns' +import { es } from 'date-fns/locale' +import { DashboardLayout } from '@/components/layout/DashboardLayout' + +export default function IncomesPage() { + const incomes = useFinanzasStore((state) => state.incomes) || [] + const addIncome = useFinanzasStore((state) => state.addIncome) + const removeIncome = useFinanzasStore((state) => state.removeIncome) + + const [newIncome, setNewIncome] = useState({ + amount: '', + description: '', + category: 'salary' as const, + }) + + const handleAdd = () => { + if (!newIncome.amount || !newIncome.description) return + + addIncome({ + amount: parseFloat(newIncome.amount), + description: newIncome.description, + category: newIncome.category, + date: new Date().toISOString(), + }) + setNewIncome({ amount: '', description: '', category: 'salary' }) + } + + const totalIncomes = incomes.reduce((acc, curr) => acc + curr.amount, 0) + + return ( + +
+
+

+ Ingresos +

+
+

Total Acumulado

+

+ ${totalIncomes.toLocaleString()} +

+
+
+ +
+ {/* Formulario */} +
+
+

Nuevo Ingreso

+
+
+
+ + + setNewIncome({ ...newIncome, description: e.target.value }) + } + className="w-full bg-slate-700 border-slate-600 border rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500" + /> +
+
+ +
+ + + setNewIncome({ ...newIncome, amount: e.target.value }) + } + className="w-full pl-9 bg-slate-700 border-slate-600 border rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500" + /> +
+
+
+ + +
+ +
+
+ + {/* Lista */} +
+
+

Historial de Ingresos

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

+ No hay ingresos registrados aún. +

+ ) : ( +
+ {incomes + .sort( + (a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ) + .map((income) => ( +
+
+

+ {income.description} +

+

+ {income.category} •{' '} + {format(new Date(income.date), "d 'de' MMMM", { + locale: es, + })} +

+
+
+ + +${income.amount.toLocaleString()} + + +
+
+ ))} +
+ )} +
+
+
+
+
+ ) +} 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/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..47970fd --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Lock, User, Key, ArrowRight, ShieldCheck, Loader2 } from 'lucide-react'; +import Link from 'next/link'; + +export default function LoginPage() { + const router = useRouter(); + const [step, setStep] = useState<'credentials' | 'otp'>('credentials'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [formData, setFormData] = useState({ + username: '', + password: '', + otp: '' + }); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: formData.username, password: formData.password }) + }); + + const data = await res.json(); + + if (!res.ok) throw new Error(data.error || 'Error al iniciar sesión'); + + if (data.requireOtp) { + setStep('otp'); + } else { + router.push('/'); + router.refresh(); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleVerifyOtp = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const res = await fetch('/api/auth/verify-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: formData.username, otp: formData.otp }) + }); + + const data = await res.json(); + + if (!res.ok) throw new Error(data.error || 'Código incorrecto'); + + router.push('/'); + router.refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+

Bienvenido

+

Sistema de Finanzas Personales

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {step === 'credentials' ? ( +
+
+ +
+ + setFormData({...formData, username: e.target.value})} + className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all placeholder:text-slate-600" + placeholder="Ingresa tu usuario" + /> +
+
+ +
+ +
+ + setFormData({...formData, password: e.target.value})} + className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all placeholder:text-slate-600" + placeholder="••••••••" + /> +
+
+ + +
+ ) : ( +
+
+
+ +
+

Verificación de Identidad

+

+ Hemos enviado un código a tu Telegram. +
Ingrésalo para continuar. +

+
+ +
+ setFormData({...formData, otp: e.target.value.replace(/\D/g, '')})} + className="w-full text-center text-2xl tracking-[0.5em] font-mono py-3 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" + placeholder="000000" + /> +
+ + + + +
+ )} +
+ +
+

+ ¿No tienes cuenta?{' '} + + Regístrate aquí + +

+
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..f5601c7 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useEffect, useState } from 'react' +import { SummarySection, QuickActions, RecentActivity } from '@/components/dashboard' +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' +import { DashboardLayout } from '@/components/layout/DashboardLayout' + +export default function Home() { + // Datos del store + const markAlertAsRead = useFinanzasStore((state) => state.markAlertAsRead) + const deleteAlert = useFinanzasStore((state) => state.deleteAlert) + + // Alertas + const { unreadAlerts, 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]) + + // 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 ( + +
+ {/* Alertas destacadas */} + {topAlerts.length > 0 && ( +
+ {topAlerts.map((alert) => ( + deleteAlert(alert.id)} + onMarkRead={() => markAlertAsRead(alert.id)} + /> + ))} +
+ )} + + {/* Sección de resumen */} + + + {/* Acciones rápidas */} + + + {/* Actividad reciente */} + +
+ + {/* Modales */} + setIsAddDebtModalOpen(false)} + /> + + setIsAddCardModalOpen(false)} + /> + + setIsAddPaymentModalOpen(false)} + /> +
+ ) +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..44f5191 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { createContext, useContext, useState, ReactNode } from "react"; +import { DataSync } from "@/components/DataSync"; + +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/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..5cc68b1 --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { UserPlus, User, Key, MessageSquare, Loader2 } from 'lucide-react'; +import Link from 'next/link'; + +export default function RegisterPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [formData, setFormData] = useState({ + username: '', + password: '', + chatId: '' + }); + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + const data = await res.json(); + + if (!res.ok) throw new Error(data.error || 'Error al registrarse'); + + router.push('/'); + router.refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+

Crear Cuenta

+

Configura tu acceso a Finanzas

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + setFormData({...formData, username: e.target.value})} + className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all placeholder:text-slate-600" + placeholder="Elige un usuario" + /> +
+
+ +
+ +
+ + setFormData({...formData, password: e.target.value})} + className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all placeholder:text-slate-600" + placeholder="Mínimo 6 caracteres" + minLength={6} + /> +
+
+ +
+ +
+ + setFormData({...formData, chatId: e.target.value})} + className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all placeholder:text-slate-600" + placeholder="Ej: 123456789" + /> +
+

+ ℹ️ + Envía /start a tu bot para obtener este ID. +

+
+ + +
+
+ +
+

+ ¿Ya tienes cuenta?{' '} + + Inicia Sesión + +

+
+
+
+ ); +} diff --git a/app/services/page.tsx b/app/services/page.tsx new file mode 100644 index 0000000..8ba01f5 --- /dev/null +++ b/app/services/page.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useState } from 'react' +import { useFinanzasStore } from '@/lib/store' +import { predictNextBill, calculateTrend } from '@/lib/predictions' +import { formatCurrency } from '@/lib/utils' +import { Zap, Droplets, Flame, Wifi, TrendingUp, TrendingDown, Plus, History } from 'lucide-react' +import { cn } from '@/lib/utils' +import { AddServiceModal } from '@/components/modals/AddServiceModal' +import { DashboardLayout } from '@/components/layout/DashboardLayout' + +const SERVICES = [ + { id: 'electricity', label: 'Luz (Electricidad)', icon: Zap, color: 'text-yellow-400', bg: 'bg-yellow-400/10' }, + { id: 'water', label: 'Agua', icon: Droplets, color: 'text-blue-400', bg: 'bg-blue-400/10' }, + { id: 'gas', label: 'Gas', icon: Flame, color: 'text-orange-400', bg: 'bg-orange-400/10' }, + { id: 'internet', label: 'Internet', icon: Wifi, color: 'text-cyan-400', bg: 'bg-cyan-400/10' }, +] + +export default function ServicesPage() { + const serviceBills = useFinanzasStore((state) => state.serviceBills) + const [isAddModalOpen, setIsAddModalOpen] = useState(false) + + return ( + +
+ + {/* Header */} +
+
+

Servicios y Predicciones

+

Gestiona tus consumos de Luz, Agua y Gas.

+
+ +
+ + {/* Service Cards */} +
+ {SERVICES.map((service) => { + const Icon = service.icon + const prediction = predictNextBill(serviceBills, service.id as any) + const trend = calculateTrend(serviceBills, service.id as any) + const lastBill = serviceBills + .filter(b => b.type === service.id) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0] + + return ( +
+
+
+ +
+ {trend !== 0 && ( +
0 ? "bg-red-500/10 text-red-400" : "bg-emerald-500/10 text-emerald-400")}> + {trend > 0 ? : } + {Math.abs(trend).toFixed(0)}% +
+ )} +
+ +
+

{service.label}

+
+

+ {formatCurrency(prediction || (lastBill?.amount ?? 0))} +

+ {prediction > 0 && (est.)} +
+

+ {lastBill + ? `Último: ${formatCurrency(lastBill.amount)}` + : 'Sin historial'} +

+
+
+ ) + })} +
+ + {/* History List */} +
+
+ +

Historial de Pagos

+
+
+ {serviceBills.length === 0 ? ( +
+ No hay facturas registradas. Comienza agregando una para ver predicciones. +
+ ) : ( + serviceBills + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .map((bill) => { + const service = SERVICES.find(s => s.id === bill.type) + const Icon = service?.icon || Zap + + return ( +
+
+
+ +
+
+

{service?.label || bill.type}

+
+

{new Date(bill.date).toLocaleDateString('es-AR', { dateStyle: 'long' })}

+ {bill.usage && ( + <> + +

+ Consumo: {bill.usage} {bill.unit} +

+ + )} +
+
+
+
+

{formatCurrency(bill.amount)}

+
+

{bill.period}

+ {bill.usage && bill.amount && ( +

+ {formatCurrency(bill.amount / bill.usage)} / {bill.unit} +

+ )} +
+
+
+ ) + }) + )} +
+
+ + setIsAddModalOpen(false)} /> +
+
+ ) +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..fc5a939 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,372 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Save, Plus, Trash2, Bot, MessageSquare, Key, Link as LinkIcon, Lock, Send, CheckCircle2, XCircle, Loader2, Sparkles, Box } from 'lucide-react' +import { cn } from '@/lib/utils' +import { AIServiceConfig, AppSettings } from '@/lib/types' +import { DashboardLayout } from '@/components/layout/DashboardLayout' + +export default function SettingsPage() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [settings, setSettings] = useState({ + telegram: { botToken: '', chatId: '' }, + aiProviders: [] + }) + const [message, setMessage] = useState<{ text: string, type: 'success' | 'error' } | null>(null) + + // Test loading states + const [testingTelegram, setTestingTelegram] = useState(false) + const [testingAI, setTestingAI] = useState(null) + const [detectingModels, setDetectingModels] = useState(null) + const [availableModels, setAvailableModels] = useState>({}) + + useEffect(() => { + fetch('/api/settings') + .then(res => res.json()) + .then(data => { + setSettings(data) + setLoading(false) + }) + .catch(err => { + console.error(err) + setLoading(false) + setMessage({ text: 'Error cargando configuración', type: 'error' }) + }) + }, []) + + const handleSave = async () => { + setSaving(true) + setMessage(null) + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }) + + if (!res.ok) throw new Error('Error saving') + + setMessage({ text: 'Configuración guardada correctamente', type: 'success' }) + } catch (err) { + setMessage({ text: 'Error al guardar la configuración', type: 'error' }) + } finally { + setSaving(false) + } + } + + const testTelegram = async () => { + setTestingTelegram(true) + setMessage(null) + try { + const res = await fetch('/api/test/telegram', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings.telegram) + }) + const data = await res.json() + + if (data.success) { + setMessage({ text: 'Mensaje de prueba enviado con éxito ✅', type: 'success' }) + } else { + setMessage({ text: `Error: ${data.error}`, type: 'error' }) + } + } catch (err: any) { + setMessage({ text: 'Error de conexión al probar Telegram', type: 'error' }) + } finally { + setTestingTelegram(false) + } + } + + const testAI = async (provider: AIServiceConfig) => { + setTestingAI(provider.id) + setMessage(null) + try { + const res = await fetch('/api/test/ai', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(provider) + }) + const data = await res.json() + + if (data.success) { + setMessage({ text: `Conexión exitosa con ${provider.model || provider.name} (${data.latency}ms) ✅`, type: 'success' }) + } else { + setMessage({ text: `Error con ${provider.name}: ${data.error}`, type: 'error' }) + } + } catch (err) { + setMessage({ text: 'Error al conectar con el proveedor', type: 'error' }) + } finally { + setTestingAI(null) + } + } + + const detectModels = async (provider: AIServiceConfig) => { + setDetectingModels(provider.id) + setMessage(null) + try { + const res = await fetch('/api/proxy/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: provider.endpoint, token: provider.token }) + }) + const data = await res.json() + + if (data.success && data.models.length > 0) { + setAvailableModels(prev => ({ ...prev, [provider.id]: data.models })) + // Auto select first if none selected + if (!provider.model) { + updateProvider(provider.id, 'model', data.models[0]) + } + setMessage({ text: `Se detectaron ${data.models.length} modelos ✅`, type: 'success' }) + } else { + setMessage({ text: `No se pudieron detectar modelos. Ingrésalo manualmente.`, type: 'error' }) + } + } catch (err) { + console.error(err) + setMessage({ text: 'Error al consultar modelos', type: 'error' }) + } finally { + setDetectingModels(null) + } + } + + const addProvider = () => { + if (settings.aiProviders.length >= 3) return + setSettings(prev => ({ + ...prev, + aiProviders: [ + ...prev.aiProviders, + { id: crypto.randomUUID(), name: '', endpoint: '', token: '', model: '' } + ] + })) + } + + const removeProvider = (id: string) => { + setSettings(prev => ({ + ...prev, + aiProviders: prev.aiProviders.filter(p => p.id !== id) + })) + } + + const updateProvider = (id: string, field: keyof AIServiceConfig, value: string) => { + setSettings(prev => ({ + ...prev, + aiProviders: prev.aiProviders.map(p => + p.id === id ? { ...p, [field]: value } : p + ) + })) + } + + if (loading) return
Cargando configuración...
+ + return ( + +
+ +
+
+

Configuración

+

Gestiona la integración con Telegram e Inteligencia Artificial.

+
+ +
+ + {message && ( +
+ {message.type === 'success' ? : } + {message.text} +
+ )} + + {/* Telegram Configuration */} +
+
+
+ +

Telegram Bot

+
+ +
+ +
+
+ + setSettings({ ...settings, telegram: { ...settings.telegram, botToken: e.target.value } })} + className="w-full px-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white font-mono text-sm outline-none transition-all placeholder:text-slate-700" + /> +

El token que te da @BotFather.

+
+ +
+ + setSettings({ ...settings, telegram: { ...settings.telegram, chatId: e.target.value } })} + className="w-full px-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white font-mono text-sm outline-none transition-all placeholder:text-slate-700" + /> +

Tu ID numérico de Telegram (o el ID del grupo).

+
+
+
+ + {/* AI Providers Configuration */} +
+
+
+ +

Proveedores de IA

+
+ +
+ +
+ {settings.aiProviders.length === 0 && ( +
+ No hay proveedores de IA configurados. Agrega uno para empezar. +
+ )} + + {settings.aiProviders.map((provider, index) => ( +
+
+

+ Provider #{index + 1} +

+
+ + +
+
+ +
+
+ + updateProvider(provider.id, 'name', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 text-white text-sm outline-none" + /> +
+ +
+
+ + updateProvider(provider.id, 'endpoint', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 text-white font-mono text-sm outline-none" + /> +
+
+ + updateProvider(provider.id, 'token', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 text-white font-mono text-sm outline-none" + /> +
+
+ + {/* Model Selection */} +
+
+ + +
+ {availableModels[provider.id] ? ( + + ) : ( + updateProvider(provider.id, 'model', e.target.value)} + className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 text-white font-mono text-sm outline-none" + /> + )} +
+ +
+
+ ))} +
+
+
+
+ ) +} diff --git a/components/DataSync.tsx b/components/DataSync.tsx new file mode 100644 index 0000000..7b7572a --- /dev/null +++ b/components/DataSync.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useFinanzasStore } from '@/lib/store'; + +export function DataSync() { + const initialized = useRef(false); + + useEffect(() => { + async function init() { + // Prevent double init in StrictMode + if (initialized.current) return; + + try { + const res = await fetch('/api/sync'); + // If 401 (unauthorized), stop here. User needs to login. + if (res.status === 401) return; + if (!res.ok) return; + + const serverData = await res.json(); + + // Comprehensive check if server has ANY data + const hasServerData = + (serverData.fixedDebts?.length ?? 0) > 0 || + (serverData.variableDebts?.length ?? 0) > 0 || + (serverData.creditCards?.length ?? 0) > 0 || + (serverData.incomes?.length ?? 0) > 0 || + (serverData.serviceBills?.length ?? 0) > 0; + + if (hasServerData) { + console.log("Sync: Hydrating from Server"); + useFinanzasStore.setState(serverData); + } else { + // Server is empty. If we have local data, push it. + // But verify we actually have local data worth pushing to avoid overwriting with empty defaults unnecessarily + const localState = useFinanzasStore.getState(); + const hasLocalData = + localState.fixedDebts.length > 0 || + localState.variableDebts.length > 0 || + localState.creditCards.length > 0 || + localState.incomes.length > 0; + + if (hasLocalData) { + console.log("Sync: Server empty, pushing Local Data"); + syncToServer(localState); + } + } + } catch (e) { + console.error("Sync init error", e); + } finally { + // Mark as initialized so subsequent changes trigger sync + initialized.current = true; + } + } + + init(); + }, []); + + // Sync on change + useEffect(() => { + const unsub = useFinanzasStore.subscribe((state) => { + // Only sync to server if we have finished initialization/hydration + if (initialized.current) { + syncToServer(state); + } + }); + return () => unsub(); + }, []); + + return null; +} + +let timeout: NodeJS.Timeout; +function syncToServer(state: any) { + clearTimeout(timeout); + timeout = setTimeout(() => { + fetch('/api/sync', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(state) + }).catch(e => console.error("Sync error", e)); + }, 2000); // Debounce 2s +} 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" + /> + +
+ +
+ +