Initial commit - cleaned for CV

This commit is contained in:
Renato97
2026-03-31 01:23:33 -03:00
commit 9c11f23af0
142 changed files with 13690 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@@ -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

161
README.md Normal file
View File

@@ -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!

46
app/alerts/page.tsx Normal file
View File

@@ -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 (
<DashboardLayout title="Alertas">
<div className="space-y-6">
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
<button
onClick={handleRegenerateAlerts}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
<RefreshCw className="h-4 w-4" />
Regenerar Alertas
</button>
<button
onClick={handleDismissAll}
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-slate-500/20"
>
Limpiar Todas
</button>
</div>
{/* Alert Panel */}
<div className="w-full">
<AlertPanel />
</div>
</div>
</DashboardLayout>
)
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/auth';
export async function POST() {
destroySession();
return NextResponse.json({ success: true });
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 })
}
}

48
app/api/settings/route.ts Normal file
View File

@@ -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 })
}
}

28
app/api/sync/route.ts Normal file
View File

@@ -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 });
}
}

59
app/api/test/ai/route.ts Normal file
View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

12
app/budget/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
'use client'
import { DashboardLayout } from '@/components/layout/DashboardLayout'
import { BudgetSection } from '@/components/budget'
export default function BudgetPage() {
return (
<DashboardLayout title="Presupuesto">
<BudgetSection />
</DashboardLayout>
)
}

12
app/cards/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
'use client';
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { CardSection } from '@/components/cards';
export default function CardsPage() {
return (
<DashboardLayout title="Tarjetas de Crédito">
<CardSection />
</DashboardLayout>
);
}

12
app/debts/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
'use client'
import { DashboardLayout } from '@/components/layout/DashboardLayout'
import { DebtSection } from '@/components/debts'
export default function DebtsPage() {
return (
<DashboardLayout title="Deudas">
<DebtSection />
</DashboardLayout>
)
}

308
app/globals.css Normal file
View File

@@ -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;
}
}

165
app/incomes/page.tsx Normal file
View File

@@ -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 (
<DashboardLayout title="Ingresos">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold flex items-center gap-2 text-white">
<TrendingUp className="text-green-500" /> Ingresos
</h2>
<div className="text-right">
<p className="text-sm text-gray-400">Total Acumulado</p>
<p className="text-2xl font-bold text-green-400">
${totalIncomes.toLocaleString()}
</p>
</div>
</div>
<div className="grid md:grid-cols-3 gap-6">
{/* Formulario */}
<div className="bg-slate-800 border border-slate-700 rounded-xl md:col-span-1 h-fit overflow-hidden">
<div className="p-6 border-b border-slate-700">
<h3 className="font-semibold text-lg text-white">Nuevo Ingreso</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="text-sm text-gray-400 block mb-2">Descripción</label>
<input
placeholder="Ej. Pago Freelance"
value={newIncome.description}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="text-sm text-gray-400 block mb-2">Monto</label>
<div className="relative">
<DollarSign className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<input
type="number"
placeholder="0.00"
value={newIncome.amount}
onChange={(e) =>
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"
/>
</div>
</div>
<div>
<label className="text-sm text-gray-400 block mb-2">Categoría</label>
<select
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={newIncome.category}
onChange={(e) =>
setNewIncome({
...newIncome,
category: e.target.value as any,
})
}
>
<option value="salary">Salario</option>
<option value="freelance">Freelance</option>
<option value="business">Negocio</option>
<option value="gift">Regalo</option>
<option value="other">Otro</option>
</select>
</div>
<button
onClick={handleAdd}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-2 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<Plus className="w-4 h-4" /> Agregar Ingreso
</button>
</div>
</div>
{/* Lista */}
<div className="bg-slate-800 border border-slate-700 rounded-xl md:col-span-2 overflow-hidden">
<div className="p-6 border-b border-slate-700">
<h3 className="font-semibold text-lg text-white">Historial de Ingresos</h3>
</div>
<div className="p-6">
{incomes.length === 0 ? (
<p className="text-center text-gray-500 py-8">
No hay ingresos registrados aún.
</p>
) : (
<div className="space-y-4">
{incomes
.sort(
(a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
.map((income) => (
<div
key={income.id}
className="flex items-center justify-between p-4 bg-slate-700/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-colors"
>
<div>
<p className="font-semibold text-white">
{income.description}
</p>
<p className="text-xs text-gray-400 capitalize">
{income.category} {' '}
{format(new Date(income.date), "d 'de' MMMM", {
locale: es,
})}
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-emerald-400 font-mono font-bold">
+${income.amount.toLocaleString()}
</span>
<button
onClick={() => removeIncome(income.id)}
className="text-slate-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</DashboardLayout>
)
}

30
app/layout.tsx Normal file
View File

@@ -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 (
<html lang="es" className={inter.variable} suppressHydrationWarning>
<body className={`${inter.className} antialiased min-h-screen bg-slate-950 text-slate-50`}>
<Providers>{children}</Providers>
</body>
</html>
);
}

193
app/login/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl shadow-xl overflow-hidden">
{/* Header */}
<div className="bg-slate-950/50 p-6 text-center border-b border-slate-800">
<div className="mx-auto w-12 h-12 bg-emerald-500/10 rounded-xl flex items-center justify-center mb-4">
<Lock className="w-6 h-6 text-emerald-500" />
</div>
<h1 className="text-2xl font-bold text-white mb-1">Bienvenido</h1>
<p className="text-slate-400 text-sm">Sistema de Finanzas Personales</p>
</div>
<div className="p-6">
{error && (
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm text-center">
{error}
</div>
)}
{step === 'credentials' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Usuario</label>
<div className="relative">
<User className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
<input
type="text"
required
value={formData.username}
onChange={e => 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"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Contraseña</label>
<div className="relative">
<Key className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
<input
type="password"
required
value={formData.password}
onChange={e => 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="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{loading ? <Loader2 className="animate-spin w-5 h-5" /> : (
<>
Ingresar <ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
) : (
<form onSubmit={handleVerifyOtp} className="space-y-4 animate-in slide-in-from-right-8 fade-in duration-300">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-500/10 rounded-full mb-4">
<ShieldCheck className="w-8 h-8 text-blue-500" />
</div>
<h3 className="text-lg font-medium text-white">Verificación de Identidad</h3>
<p className="text-slate-400 text-sm mt-1">
Hemos enviado un código a tu Telegram.
<br />Ingrésalo para continuar.
</p>
</div>
<div className="space-y-2">
<input
type="text"
required
autoFocus
maxLength={6}
value={formData.otp}
onChange={e => 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"
/>
</div>
<button
type="submit"
disabled={loading || formData.otp.length !== 6}
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? <Loader2 className="animate-spin w-5 h-5" /> : 'Verificar Código'}
</button>
<button
type="button"
onClick={() => setStep('credentials')}
className="w-full text-slate-500 text-sm hover:text-slate-300 transition-colors"
>
Volver atrás
</button>
</form>
)}
</div>
<div className="bg-slate-950/50 p-4 text-center border-t border-slate-800">
<p className="text-slate-500 text-sm">
¿No tienes cuenta?{' '}
<Link href="/register" className="text-emerald-500 hover:text-emerald-400 font-medium hover:underline">
Regístrate aquí
</Link>
</p>
</div>
</div>
</div>
);
}

94
app/page.tsx Normal file
View File

@@ -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 (
<DashboardLayout title="Dashboard">
<div className="space-y-6">
{/* Alertas destacadas */}
{topAlerts.length > 0 && (
<div className="space-y-3">
{topAlerts.map((alert) => (
<AlertBanner
key={alert.id}
alert={alert}
onDismiss={() => deleteAlert(alert.id)}
onMarkRead={() => markAlertAsRead(alert.id)}
/>
))}
</div>
)}
{/* Sección de resumen */}
<SummarySection />
{/* Acciones rápidas */}
<QuickActions
onAddDebt={handleAddDebt}
onAddCard={handleAddCard}
onAddPayment={handleAddPayment}
/>
{/* Actividad reciente */}
<RecentActivity limit={5} />
</div>
{/* Modales */}
<AddDebtModal
isOpen={isAddDebtModalOpen}
onClose={() => setIsAddDebtModalOpen(false)}
/>
<AddCardModal
isOpen={isAddCardModalOpen}
onClose={() => setIsAddCardModalOpen(false)}
/>
<AddPaymentModal
isOpen={isAddPaymentModalOpen}
onClose={() => setIsAddPaymentModalOpen(false)}
/>
</DashboardLayout>
)
}

43
app/providers.tsx Normal file
View File

@@ -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<SidebarContextType | undefined>(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 (
<SidebarContext.Provider
value={{
isOpen: isSidebarOpen,
toggle: toggleSidebar,
close: closeSidebar,
open: openSidebar,
}}
>
<DataSync />
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a Providers");
}
return context;
}

135
app/register/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl shadow-xl overflow-hidden">
{/* Header */}
<div className="bg-slate-950/50 p-6 text-center border-b border-slate-800">
<div className="mx-auto w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center mb-4">
<UserPlus className="w-6 h-6 text-blue-500" />
</div>
<h1 className="text-2xl font-bold text-white mb-1">Crear Cuenta</h1>
<p className="text-slate-400 text-sm">Configura tu acceso a Finanzas</p>
</div>
<div className="p-6">
{error && (
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm text-center">
{error}
</div>
)}
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Usuario</label>
<div className="relative">
<User className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
<input
type="text"
required
value={formData.username}
onChange={e => 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"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Contraseña</label>
<div className="relative">
<Key className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
<input
type="password"
required
value={formData.password}
onChange={e => 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}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Telegram Chat ID</label>
<div className="relative">
<MessageSquare className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
<input
type="text"
required
value={formData.chatId}
onChange={e => 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"
/>
</div>
<p className="text-[10px] text-slate-500 flex gap-1">
<span></span>
Envía /start a tu bot para obtener este ID.
</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{loading ? <Loader2 className="animate-spin w-5 h-5" /> : 'Registrar Cuenta'}
</button>
</form>
</div>
<div className="bg-slate-950/50 p-4 text-center border-t border-slate-800">
<p className="text-slate-500 text-sm">
¿Ya tienes cuenta?{' '}
<Link href="/login" className="text-blue-500 hover:text-blue-400 font-medium hover:underline">
Inicia Sesión
</Link>
</p>
</div>
</div>
</div>
);
}

145
app/services/page.tsx Normal file
View File

@@ -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 (
<DashboardLayout title="Servicios">
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white">Servicios y Predicciones</h2>
<p className="text-slate-400 text-sm">Gestiona tus consumos de Luz, Agua y Gas.</p>
</div>
<button
onClick={() => setIsAddModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-cyan-500 hover:bg-cyan-400 text-white rounded-lg transition shadow-lg shadow-cyan-500/20 font-medium self-start sm:self-auto"
>
<Plus size={18} /> Nuevo Pago
</button>
</div>
{/* Service Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{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 (
<div key={service.id} className="bg-slate-900 border border-slate-800 rounded-xl p-5 space-y-4">
<div className="flex items-center justify-between">
<div className={cn("p-2 rounded-lg", service.bg)}>
<Icon className={cn("w-6 h-6", service.color)} />
</div>
{trend !== 0 && (
<div className={cn("flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-full", trend > 0 ? "bg-red-500/10 text-red-400" : "bg-emerald-500/10 text-emerald-400")}>
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
{Math.abs(trend).toFixed(0)}%
</div>
)}
</div>
<div>
<p className="text-slate-400 text-sm font-medium">{service.label}</p>
<div className="flex items-baseline gap-2">
<h3 className="text-2xl font-bold text-white mt-1">
{formatCurrency(prediction || (lastBill?.amount ?? 0))}
</h3>
{prediction > 0 && <span className="text-xs text-slate-500 font-mono">(est.)</span>}
</div>
<p className="text-xs text-slate-500 mt-1">
{lastBill
? `Último: ${formatCurrency(lastBill.amount)}`
: 'Sin historial'}
</p>
</div>
</div>
)
})}
</div>
{/* History List */}
<div className="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<div className="p-5 border-b border-slate-800 flex items-center gap-2">
<History size={18} className="text-slate-400" />
<h3 className="text-lg font-semibold text-white">Historial de Pagos</h3>
</div>
<div className="divide-y divide-slate-800">
{serviceBills.length === 0 ? (
<div className="p-8 text-center text-slate-500 text-sm">
No hay facturas registradas. Comienza agregando una para ver predicciones.
</div>
) : (
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 (
<div key={bill.id} className="p-4 flex items-center justify-between hover:bg-slate-800/50 transition-colors">
<div className="flex items-center gap-4">
<div className={cn("p-2 rounded-lg", service?.bg || 'bg-slate-800')}>
<Icon className={cn("w-5 h-5", service?.color || 'text-slate-400')} />
</div>
<div>
<p className="text-white font-medium capitalize">{service?.label || bill.type}</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<p className="text-xs text-slate-500 capitalize">{new Date(bill.date).toLocaleDateString('es-AR', { dateStyle: 'long' })}</p>
{bill.usage && (
<>
<span className="hidden sm:inline text-slate-700"></span>
<p className="text-xs text-slate-400">
Consumo: <span className="text-slate-300 font-medium">{bill.usage} {bill.unit}</span>
</p>
</>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="text-white font-mono font-medium">{formatCurrency(bill.amount)}</p>
<div className="flex flex-col items-end">
<p className="text-xs text-slate-500 uppercase">{bill.period}</p>
{bill.usage && bill.amount && (
<p className="text-[10px] text-cyan-500/80 font-mono">
{formatCurrency(bill.amount / bill.usage)} / {bill.unit}
</p>
)}
</div>
</div>
</div>
)
})
)}
</div>
</div>
<AddServiceModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
</div>
</DashboardLayout>
)
}

372
app/settings/page.tsx Normal file
View File

@@ -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<AppSettings>({
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<string | null>(null)
const [detectingModels, setDetectingModels] = useState<string | null>(null)
const [availableModels, setAvailableModels] = useState<Record<string, string[]>>({})
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 <div className="p-8 text-center text-slate-400">Cargando configuración...</div>
return (
<DashboardLayout title="Configuración">
<div className="max-w-4xl mx-auto space-y-8 pb-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white">Configuración</h2>
<p className="text-slate-400 text-sm">Gestiona la integración con Telegram e Inteligencia Artificial.</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2 bg-emerald-500 hover:bg-emerald-400 text-white rounded-lg transition shadow-lg shadow-emerald-500/20 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={18} />
{saving ? 'Guardando...' : 'Guardar Cambios'}
</button>
</div>
{message && (
<div className={cn(
"p-4 rounded-lg text-sm font-medium border flex items-center gap-2 animate-in fade-in slide-in-from-top-2",
message.type === 'success' ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400" : "bg-red-500/10 border-red-500/20 text-red-400"
)}>
{message.type === 'success' ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
{message.text}
</div>
)}
{/* Telegram Configuration */}
<section className="space-y-4">
<div className="flex items-center justify-between text-white border-b border-slate-800 pb-2">
<div className="flex items-center gap-2">
<Bot className="text-cyan-400" />
<h2 className="text-lg font-semibold">Telegram Bot</h2>
</div>
<button
onClick={testTelegram}
disabled={testingTelegram || !settings.telegram.botToken || !settings.telegram.chatId}
className="text-xs flex items-center gap-1.5 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/20 px-3 py-1.5 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{testingTelegram ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Probar Envío
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6 bg-slate-900 border border-slate-800 rounded-xl">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Key size={12} /> Bot Token
</label>
<input
type="text"
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
value={settings.telegram.botToken}
onChange={(e) => 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"
/>
<p className="text-[10px] text-slate-500">El token que te da @BotFather.</p>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
<MessageSquare size={12} /> Chat ID
</label>
<input
type="text"
placeholder="123456789"
value={settings.telegram.chatId}
onChange={(e) => 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"
/>
<p className="text-[10px] text-slate-500">Tu ID numérico de Telegram (o el ID del grupo).</p>
</div>
</div>
</section>
{/* AI Providers Configuration */}
<section className="space-y-4">
<div className="flex items-center justify-between text-white border-b border-slate-800 pb-2">
<div className="flex items-center gap-2">
<Bot className="text-purple-400" />
<h2 className="text-lg font-semibold">Proveedores de IA</h2>
</div>
<button
onClick={addProvider}
disabled={settings.aiProviders.length >= 3}
className="text-xs flex items-center gap-1 bg-slate-800 hover:bg-slate-700 text-slate-200 px-3 py-1.5 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={14} /> Agregar Provider ({settings.aiProviders.length}/3)
</button>
</div>
<div className="space-y-4">
{settings.aiProviders.length === 0 && (
<div className="p-8 text-center text-slate-500 border border-dashed border-slate-800 rounded-xl">
No hay proveedores de IA configurados. Agrega uno para empezar.
</div>
)}
{settings.aiProviders.map((provider, index) => (
<div key={provider.id} className="p-6 bg-slate-900 border border-slate-800 rounded-xl relative group">
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-semibold text-slate-300 bg-slate-950 inline-block px-3 py-1 rounded-md border border-slate-800">
Provider #{index + 1}
</h3>
<div className="flex gap-2">
<button
onClick={() => testAI(provider)}
disabled={testingAI === provider.id || !provider.endpoint || !provider.token || !provider.model}
className={cn("text-xs flex items-center gap-1 bg-slate-800 hover:bg-slate-700 text-purple-300 border border-purple-500/20 px-2 py-1.5 rounded-lg transition disabled:opacity-50", !provider.model && "opacity-50")}
title="Verificar conexión"
>
{testingAI === provider.id ? <Loader2 size={12} className="animate-spin" /> : <LinkIcon size={12} />}
Test
</button>
<button
onClick={() => removeProvider(provider.id)}
className="text-slate-500 hover:text-red-400 transition-colors p-1.5 hover:bg-red-500/10 rounded-lg"
title="Eliminar"
>
<Trash2 size={16} />
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Nombre</label>
<input
type="text"
placeholder="Ej: MiniMax, Z.ai"
value={provider.name}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
<LinkIcon size={12} /> Endpoint URL
</label>
<input
type="text"
placeholder="https://api.example.com/v1"
value={provider.endpoint}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Lock size={12} /> API Key / Token
</label>
<input
type="password"
placeholder="sk-..."
value={provider.token}
onChange={(e) => 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"
/>
</div>
</div>
{/* Model Selection */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Box size={12} /> Model
</label>
<button
onClick={() => detectModels(provider)}
disabled={detectingModels === provider.id || !provider.endpoint || !provider.token}
className="text-[10px] flex items-center gap-1 text-cyan-400 hover:text-cyan-300 disabled:opacity-50"
>
{detectingModels === provider.id ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
Auto Detectar
</button>
</div>
{availableModels[provider.id] ? (
<select
value={provider.model || ''}
onChange={(e) => 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 text-sm outline-none"
>
<option value="" disabled>Selecciona un modelo</option>
{availableModels[provider.id].map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
) : (
<input
type="text"
placeholder="Ej: gpt-3.5-turbo, glm-4"
value={provider.model || ''}
onChange={(e) => 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"
/>
)}
</div>
</div>
</div>
))}
</div>
</section>
</div>
</DashboardLayout>
)
}

83
components/DataSync.tsx Normal file
View File

@@ -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
}

View File

@@ -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 (
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-red-500 animate-pulse" />
)
}
return (
<span
className={cn(
'inline-flex items-center justify-center min-w-[20px] h-5 px-1.5',
'rounded-full bg-red-500 text-white text-xs font-medium',
'animate-pulse'
)}
>
{count > 99 ? '99+' : count}
</span>
)
}

View File

@@ -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 (
<div
className={cn(
'relative overflow-hidden rounded-r-lg border-l-4 p-4',
'transition-all duration-300 ease-out',
'animate-in slide-in-from-top-2',
isExiting && 'animate-out slide-out-to-top-2 opacity-0',
styles.bg,
styles.border
)}
role="alert"
>
<div className="flex items-start gap-3">
<div className={cn('flex-shrink-0 mt-0.5', styles.icon)}>
<AlertIcon type={alert.type} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-white text-sm">{alert.title}</h4>
<p className="mt-1 text-sm text-gray-300">{alert.message}</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!alert.isRead && (
<button
onClick={handleMarkRead}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-400 hover:text-white hover:bg-white/10',
'focus:outline-none focus:ring-2 focus:ring-white/20'
)}
title="Marcar como leída"
aria-label="Marcar como leída"
>
<Check className="h-4 w-4" />
</button>
)}
<button
onClick={handleDismiss}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-400 hover:text-white hover:bg-white/10',
'focus:outline-none focus:ring-2 focus:ring-white/20'
)}
title="Cerrar"
aria-label="Cerrar alerta"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -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<Alert['type'], LucideIcon> = {
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 <Icon className={cn('h-5 w-5', className)} />
}

View File

@@ -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 (
<div
className={cn(
'group relative flex items-center gap-3 p-3 rounded-lg',
'transition-all duration-200',
'hover:bg-white/5',
isExiting && 'opacity-0 -translate-x-4',
!alert.isRead && 'bg-white/[0.02]'
)}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
role="listitem"
>
{/* Unread indicator */}
{!alert.isRead && (
<span className="absolute left-1 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-blue-500" />
)}
{/* Icon */}
<div
className={cn(
'flex-shrink-0',
severityStyles[alert.severity],
!alert.isRead && 'ml-3'
)}
>
<AlertIcon type={alert.type} className="h-4 w-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={cn(
'text-sm truncate',
alert.isRead ? 'text-gray-400' : 'text-white font-medium'
)}
>
{alert.title}
</p>
<p className="text-xs text-gray-500 mt-0.5">{getRelativeTime(alert.date)}</p>
</div>
{/* Actions */}
<div
className={cn(
'flex items-center gap-1 transition-opacity duration-200',
showActions ? 'opacity-100' : 'opacity-0'
)}
>
{!alert.isRead && (
<button
onClick={handleMarkRead}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-500 hover:text-green-400 hover:bg-green-400/10',
'focus:outline-none focus:ring-2 focus:ring-green-400/20'
)}
title="Marcar como leída"
aria-label="Marcar como leída"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={handleDelete}
className={cn(
'p-1.5 rounded-md transition-colors',
'text-gray-500 hover:text-red-400 hover:bg-red-400/10',
'focus:outline-none focus:ring-2 focus:ring-red-400/20'
)}
title="Eliminar"
aria-label="Eliminar alerta"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}

View File

@@ -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<TabType>('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 (
<div className="w-full max-w-md bg-gray-900 rounded-xl border border-gray-800 shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<div className="relative">
<Bell className="h-5 w-5 text-gray-400" />
{unreadCount > 0 && <AlertBadge count={unreadCount} variant="dot" />}
</div>
<h3 className="font-semibold text-white">Alertas</h3>
{unreadCount > 0 && (
<span className="text-xs text-gray-500">({unreadCount})</span>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className={cn(
'p-2 rounded-md transition-colors',
'text-gray-500 hover:text-green-400 hover:bg-green-400/10',
'focus:outline-none focus:ring-2 focus:ring-green-400/20'
)}
title="Marcar todas como leídas"
aria-label="Marcar todas como leídas"
>
<CheckCheck className="h-4 w-4" />
</button>
)}
{alerts.length > 0 && (
<button
onClick={handleClearAll}
className={cn(
'p-2 rounded-md transition-colors',
'text-gray-500 hover:text-red-400 hover:bg-red-400/10',
'focus:outline-none focus:ring-2 focus:ring-red-400/20'
)}
title="Limpiar todas"
aria-label="Limpiar todas las alertas"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Tabs */}
{alerts.length > 0 && (
<div className="flex border-b border-gray-800">
<button
onClick={() => setActiveTab('all')}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500/20',
activeTab === 'all'
? 'text-white border-b-2 border-blue-500'
: 'text-gray-500 hover:text-gray-300'
)}
>
Todas
<span className="ml-1.5 text-xs text-gray-600">({alerts.length})</span>
</button>
<button
onClick={() => setActiveTab('unread')}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500/20',
activeTab === 'unread'
? 'text-white border-b-2 border-blue-500'
: 'text-gray-500 hover:text-gray-300'
)}
>
No leídas
{unreadCount > 0 && (
<span className="ml-1.5 text-xs text-blue-400">({unreadCount})</span>
)}
</button>
</div>
)}
{/* Alert List */}
<div className="max-h-[400px] overflow-y-auto">
{displayedAlerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="h-12 w-12 rounded-full bg-gray-800 flex items-center justify-center mb-3">
<Inbox className="h-6 w-6 text-gray-600" />
</div>
<p className="text-gray-400 text-sm">
{activeTab === 'unread'
? 'No tienes alertas sin leer'
: 'No tienes alertas'}
</p>
<p className="text-gray-600 text-xs mt-1">
Las alertas aparecerán cuando haya pagos próximos o eventos importantes
</p>
</div>
) : (
<div className="divide-y divide-gray-800/50" role="list">
{displayedAlerts.map((alert) => (
<AlertItem
key={alert.id}
alert={alert}
onMarkRead={() => markAlertAsRead(alert.id)}
onDelete={() => deleteAlert(alert.id)}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

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

View File

@@ -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,
}
}

View File

@@ -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 <TrendingUp className="w-4 h-4 text-emerald-400" />
case 'down':
return <TrendingDown className="w-4 h-4 text-red-400" />
default:
return <Minus className="w-4 h-4 text-slate-500" />
}
}
const getTrendText = () => {
switch (trend) {
case 'up':
return <span className="text-emerald-400 text-xs">Positivo</span>
case 'down':
return <span className="text-red-400 text-xs">Negativo</span>
default:
return <span className="text-slate-500 text-xs">Neutral</span>
}
}
const textColor = color || 'text-white'
return (
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<div className="flex items-center justify-between">
<p className="text-slate-400 text-sm">{label}</p>
{getTrendIcon()}
</div>
<p className={cn('text-2xl font-mono font-semibold mt-2', textColor)}>
{formatCurrency(amount)}
</p>
<div className="mt-2">{getTrendText()}</div>
</div>
)
}

View File

@@ -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<Record<string, string>>({})
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
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 = <K extends keyof typeof formData>(
field: K,
value: typeof formData[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="month" className="block text-sm font-medium text-slate-300 mb-1">
Mes <span className="text-red-400">*</span>
</label>
<select
id="month"
value={formData.month}
onChange={(e) => updateField('month', parseInt(e.target.value))}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.month ? 'border-red-500' : 'border-slate-600'
)}
>
{months.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
{errors.month && <p className="mt-1 text-sm text-red-400">{errors.month}</p>}
</div>
<div>
<label htmlFor="year" className="block text-sm font-medium text-slate-300 mb-1">
Año <span className="text-red-400">*</span>
</label>
<input
type="number"
id="year"
min="2000"
max="2100"
value={formData.year}
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-400">{errors.year}</p>}
</div>
</div>
<div>
<label htmlFor="totalIncome" className="block text-sm font-medium text-slate-300 mb-1">
Ingresos totales <span className="text-red-400">*</span>
</label>
<input
type="number"
id="totalIncome"
min="0"
step="0.01"
value={formData.totalIncome || ''}
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-400">{errors.totalIncome}</p>}
</div>
<div>
<label htmlFor="savingsGoal" className="block text-sm font-medium text-slate-300 mb-1">
Meta de ahorro <span className="text-red-400">*</span>
</label>
<input
type="number"
id="savingsGoal"
min="0"
step="0.01"
value={formData.savingsGoal || ''}
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-400">{errors.savingsGoal}</p>}
{formData.totalIncome > 0 && (
<p className="mt-1 text-sm text-slate-500">
Disponible para gastos: {((formData.totalIncome - formData.savingsGoal) / formData.totalIncome * 100).toFixed(0)}%
</p>
)}
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className={cn(
'flex-1 px-4 py-2 bg-slate-700 text-slate-200 rounded-lg font-medium',
'hover:bg-slate-600 transition-colors'
)}
>
Cancelar
</button>
<button
type="submit"
className={cn(
'flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
'hover:bg-blue-500 transition-colors'
)}
>
{initialData ? 'Guardar cambios' : 'Crear presupuesto'}
</button>
</div>
</form>
)
}

View File

@@ -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 (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-300">{label}</span>
<span className="text-sm text-slate-400">
{formatCurrency(current)} <span className="text-slate-600">/ {formatCurrency(max)}</span>
</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500 ease-out', getColorClass())}
style={{ width: `${percentage}%` }}
/>
</div>
<div className="flex justify-between mt-1">
<span className="text-xs text-slate-500">{percentage.toFixed(0)}% usado</span>
{percentage >= 100 && (
<span className="text-xs text-red-400 font-medium">Límite alcanzado</span>
)}
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex flex-col items-center">
<div className="relative">
<svg
width={radius * 2}
height={radius * 2}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
stroke="#334155"
strokeWidth={strokeWidth}
fill="transparent"
r={normalizedRadius}
cx={radius}
cy={radius}
/>
{/* Progress circle */}
<circle
stroke={colors.stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="transparent"
r={normalizedRadius}
cx={radius}
cy={radius}
style={{
strokeDasharray: `${circumference} ${circumference}`,
strokeDashoffset,
transition: 'stroke-dashoffset 0.5s ease-in-out',
}}
/>
</svg>
{/* Center content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={cn('text-3xl font-bold', colors.bg)}>
{percentage.toFixed(0)}%
</span>
<span className="text-slate-400 text-sm mt-1">usado</span>
</div>
</div>
{/* Stats below */}
<div className="mt-4 text-center">
<p className="text-slate-400 text-sm">{label}</p>
<p className="text-lg font-semibold text-white mt-1">
{formatCurrency(spent)} <span className="text-slate-500">/ {formatCurrency(total)}</span>
</p>
<p className="text-sm text-slate-500 mt-1">
{formatCurrency(remaining)} disponible
</p>
</div>
</div>
)
}

View File

@@ -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 (
<div className="bg-slate-900 min-h-screen p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Presupuesto Mensual</h1>
<p className="text-slate-400 text-sm mt-1">
{getMonthName(currentMonth)} {currentYear}
</p>
</div>
</div>
<div className="text-center py-16 bg-slate-800/50 border border-slate-700/50 rounded-lg">
<Wallet className="w-12 h-12 text-slate-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-300">
No hay presupuesto para este mes
</h3>
<p className="text-slate-500 mt-2 mb-6">
Crea un presupuesto para comenzar a gestionar tus finanzas
</p>
<button
onClick={handleCreateBudget}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
'hover:bg-blue-500 transition-colors'
)}
>
<Plus className="w-4 h-4" />
Crear presupuesto
</button>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleCloseModal}
/>
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-bold text-white mb-4">
Nuevo presupuesto
</h2>
<BudgetForm
onSubmit={handleSubmit}
onCancel={handleCloseModal}
/>
</div>
</div>
</div>
)}
</div>
</div>
)
}
return (
<div className="bg-slate-900 min-h-screen p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Presupuesto Mensual</h1>
<p className="text-slate-400 text-sm mt-1">
{getMonthName(currentMonth)} {currentYear}
</p>
</div>
<button
onClick={handleEditBudget}
className={cn(
'flex items-center gap-2 px-4 py-2 bg-slate-700 text-white rounded-lg font-medium',
'hover:bg-slate-600 transition-colors'
)}
>
<Edit3 className="w-4 h-4" />
Editar
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<BudgetCard
label="Ingresos totales"
amount={totalIncome}
trend="up"
color="text-emerald-400"
/>
<BudgetCard
label="Meta de ahorro"
amount={savingsGoal}
trend="neutral"
color="text-blue-400"
/>
<BudgetCard
label="Gastado"
amount={totalSpent}
trend={totalSpent > availableForExpenses ? 'down' : 'neutral'}
color={totalSpent > availableForExpenses ? 'text-red-400' : 'text-amber-400'}
/>
<BudgetCard
label="Disponible"
amount={remaining}
trend={remaining > 0 ? 'up' : 'down'}
color={remaining > 0 ? 'text-emerald-400' : 'text-red-400'}
/>
</div>
{/* Budget Ring and Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* Ring */}
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6 flex items-center justify-center">
<BudgetRing
spent={totalSpent}
total={availableForExpenses}
label="Presupuesto mensual"
/>
</div>
{/* Breakdown */}
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-white mb-4">Desglose de gastos</h3>
<div className="space-y-4">
<BudgetProgress
current={fixedExpenses}
max={availableForExpenses}
label="Deudas fijas pendientes"
/>
<BudgetProgress
current={variableExpenses}
max={availableForExpenses}
label="Deudas variables pendientes"
/>
<BudgetProgress
current={cardExpenses}
max={availableForExpenses}
label="Pagos de tarjetas"
/>
</div>
</div>
</div>
{/* Projection */}
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
projectedEndOfMonth > availableForExpenses ? 'bg-red-500/10' : 'bg-emerald-500/10'
)}>
{projectedEndOfMonth > availableForExpenses ? (
<AlertCircle className="w-5 h-5 text-red-400" />
) : (
<TrendingUp className="w-5 h-5 text-emerald-400" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-white">Proyección</h3>
<p className="text-slate-400 mt-1">
A tu ritmo actual de gasto ({formatCurrency(dailySpendRate)}/día),
{projectedEndOfMonth > availableForExpenses ? (
<span className="text-red-400">
{' '}terminarás el mes con un déficit de {formatCurrency(projectedEndOfMonth - availableForExpenses)}.
</span>
) : (
<span className="text-emerald-400">
{' '}terminarás el mes con un superávit de {formatCurrency(availableForExpenses - projectedEndOfMonth)}.
</span>
)}
</p>
<p className="text-slate-500 text-sm mt-2">
Quedan {daysRemaining} días en el mes
</p>
</div>
</div>
</div>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleCloseModal}
/>
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-bold text-white mb-4">
{isEditing ? 'Editar presupuesto' : 'Nuevo presupuesto'}
</h2>
<BudgetForm
onSubmit={handleSubmit}
onCancel={handleCloseModal}
initialData={isEditing ? currentBudget : undefined}
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,5 @@
export { BudgetForm } from './BudgetForm';
export { BudgetRing } from './BudgetRing';
export { BudgetProgress } from './BudgetProgress';
export { BudgetCard } from './BudgetCard';
export { BudgetSection } from './BudgetSection';

View File

@@ -0,0 +1,255 @@
'use client'
import { useState } from 'react'
import { X, Check } from 'lucide-react'
interface CardPaymentFormData {
description: string
amount: number
date: string
installments?: {
current: number
total: number
}
}
interface CardPaymentFormProps {
cardId: string
onSubmit: (data: CardPaymentFormData) => void
onCancel: () => void
}
export function CardPaymentForm({ cardId, onSubmit, onCancel }: CardPaymentFormProps) {
const today = new Date().toISOString().split('T')[0]
const [formData, setFormData] = useState<CardPaymentFormData>({
description: '',
amount: 0,
date: today,
installments: undefined,
})
const [hasInstallments, setHasInstallments] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.description.trim()) {
newErrors.description = 'La descripción es requerida'
}
if (formData.amount <= 0) {
newErrors.amount = 'El monto debe ser mayor a 0'
}
if (!formData.date) {
newErrors.date = 'La fecha es requerida'
}
if (hasInstallments && formData.installments) {
if (formData.installments.current < 1) {
newErrors.installmentCurrent = 'La cuota actual debe ser al menos 1'
}
if (formData.installments.total < 2) {
newErrors.installmentTotal = 'El total de cuotas debe ser al menos 2'
}
if (formData.installments.current > formData.installments.total) {
newErrors.installments = 'La cuota actual no puede ser mayor al total'
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validateForm()) {
onSubmit({
...formData,
installments: hasInstallments ? formData.installments : undefined,
})
}
}
const updateField = (field: keyof CardPaymentFormData, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
const updateInstallmentField = (field: 'current' | 'total', value: number) => {
setFormData((prev) => ({
...prev,
installments: {
current: field === 'current' ? value : (prev.installments?.current ?? 1),
total: field === 'total' ? value : (prev.installments?.total ?? 1),
},
}))
// Clear related errors
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[`installment${field.charAt(0).toUpperCase() + field.slice(1)}`]
delete newErrors.installments
return newErrors
})
}
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl bg-slate-800 p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Registrar Pago</h3>
<button
type="button"
onClick={onCancel}
className="rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-700 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="grid gap-4">
{/* Description */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Descripción
</label>
<input
type="text"
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Ej: Supermercado Coto"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.description && (
<p className="mt-1 text-sm text-rose-400">{errors.description}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* Amount */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Monto
</label>
<input
type="number"
min={0}
step="0.01"
value={formData.amount || ''}
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.amount && (
<p className="mt-1 text-sm text-rose-400">{errors.amount}</p>
)}
</div>
{/* Date */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Fecha
</label>
<input
type="date"
value={formData.date}
onChange={(e) => updateField('date', e.target.value)}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.date && <p className="mt-1 text-sm text-rose-400">{errors.date}</p>}
</div>
</div>
{/* Installments Toggle */}
<div className="flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-700/50 p-3">
<input
type="checkbox"
id="hasInstallments"
checked={hasInstallments}
onChange={(e) => {
setHasInstallments(e.target.checked)
if (!e.target.checked) {
setFormData((prev) => ({ ...prev, installments: undefined }))
} else {
setFormData((prev) => ({ ...prev, installments: { current: 1, total: 1 } }))
}
}}
className="h-4 w-4 rounded border-slate-500 bg-slate-600 text-indigo-600 focus:ring-indigo-500"
/>
<label htmlFor="hasInstallments" className="text-sm font-medium text-slate-300">
Este pago es en cuotas
</label>
</div>
{/* Installments Fields */}
{hasInstallments && (
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Cuota actual
</label>
<input
type="number"
min={1}
value={formData.installments?.current || ''}
onChange={(e) =>
updateInstallmentField('current', parseInt(e.target.value) || 1)
}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.installmentCurrent && (
<p className="mt-1 text-sm text-rose-400">{errors.installmentCurrent}</p>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Total de cuotas
</label>
<input
type="number"
min={2}
value={formData.installments?.total || ''}
onChange={(e) =>
updateInstallmentField('total', parseInt(e.target.value) || 2)
}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.installmentTotal && (
<p className="mt-1 text-sm text-rose-400">{errors.installmentTotal}</p>
)}
</div>
</div>
)}
{errors.installments && (
<p className="text-sm text-rose-400">{errors.installments}</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-700"
>
Cancelar
</button>
<button
type="submit"
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Check className="h-4 w-4" />
Registrar pago
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,325 @@
'use client'
import { useState, useMemo } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { CreditCardWidget } from './CreditCardWidget'
import { CreditCardForm } from './CreditCardForm'
import { CardPaymentForm } from './CardPaymentForm'
import { MiniCard } from './MiniCard'
import { CreditCard, CardPayment } from '@/lib/types'
import { formatCurrency, formatShortDate, getMonthName } from '@/lib/utils'
import { Plus, CreditCard as CreditCardIcon, Receipt, Trash2 } from 'lucide-react'
export function CardSection() {
const {
creditCards,
cardPayments,
currentMonth,
currentYear,
addCreditCard,
updateCreditCard,
deleteCreditCard,
addCardPayment,
deleteCardPayment,
} = useFinanzasStore()
const [showCardForm, setShowCardForm] = useState(false)
const [editingCard, setEditingCard] = useState<CreditCard | null>(null)
const [selectedCardId, setSelectedCardId] = useState<string>('')
const [showPaymentForm, setShowPaymentForm] = useState(false)
// Filter payments for current month
const currentMonthPayments = useMemo(() => {
return cardPayments.filter((payment) => {
const paymentDate = new Date(payment.date)
return (
paymentDate.getMonth() + 1 === currentMonth &&
paymentDate.getFullYear() === currentYear
)
})
}, [cardPayments, currentMonth, currentYear])
const handleCardSubmit = (data: Omit<CreditCard, 'id'>) => {
if (editingCard) {
updateCreditCard(editingCard.id, data)
setEditingCard(null)
} else {
addCreditCard(data)
}
setShowCardForm(false)
}
const handleEditCard = (card: CreditCard) => {
setEditingCard(card)
setShowCardForm(true)
}
const handleDeleteCard = (cardId: string) => {
if (window.confirm('¿Estás seguro de que deseas eliminar esta tarjeta?')) {
deleteCreditCard(cardId)
if (selectedCardId === cardId) {
setSelectedCardId('')
setShowPaymentForm(false)
}
}
}
const handlePaymentSubmit = (data: {
description: string
amount: number
date: string
installments?: { current: number; total: number }
}) => {
addCardPayment({
cardId: selectedCardId,
...data,
})
setShowPaymentForm(false)
}
const handleDeletePayment = (paymentId: string) => {
if (window.confirm('¿Estás seguro de que deseas eliminar este pago?')) {
deleteCardPayment(paymentId)
}
}
const getCardById = (cardId: string): CreditCard | undefined => {
return creditCards.find((card) => card.id === cardId)
}
const getCardTotalPayments = (cardId: string): number => {
return currentMonthPayments
.filter((payment) => payment.cardId === cardId)
.reduce((total, payment) => total + payment.amount, 0)
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-500/20">
<CreditCardIcon className="h-5 w-5 text-indigo-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">Tarjetas de Crédito</h2>
<p className="text-sm text-slate-400">
{getMonthName(currentMonth)} {currentYear}
</p>
</div>
</div>
<button
onClick={() => {
setEditingCard(null)
setShowCardForm(true)
}}
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Plus className="h-4 w-4" />
Agregar tarjeta
</button>
</div>
{/* Cards Grid */}
{creditCards.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-600 bg-slate-800/50 p-12 text-center">
<CreditCardIcon className="mx-auto h-12 w-12 text-slate-500" />
<h3 className="mt-4 text-lg font-medium text-slate-300">
No tienes tarjetas registradas
</h3>
<p className="mt-1 text-sm text-slate-400">
Agrega tu primera tarjeta para comenzar a gestionar tus pagos
</p>
<button
onClick={() => {
setEditingCard(null)
setShowCardForm(true)
}}
className="mt-4 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Agregar tarjeta
</button>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{creditCards.map((card) => (
<CreditCardWidget
key={card.id}
card={card}
onEdit={() => handleEditCard(card)}
onDelete={() => handleDeleteCard(card.id)}
/>
))}
</div>
)}
{/* Card Form Modal */}
{showCardForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg">
<CreditCardForm
initialData={editingCard ?? undefined}
onSubmit={handleCardSubmit}
onCancel={() => {
setShowCardForm(false)
setEditingCard(null)
}}
/>
</div>
</div>
)}
{/* Payment Section */}
{creditCards.length > 0 && (
<div className="grid gap-6 lg:grid-cols-2">
{/* Register Payment */}
<div className="rounded-xl bg-slate-800 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20">
<Receipt className="h-4 w-4 text-emerald-400" />
</div>
<h3 className="text-lg font-semibold text-white">Registrar Pago</h3>
</div>
{/* Card Selector */}
<div className="mb-4">
<label className="mb-2 block text-sm font-medium text-slate-300">
Seleccionar tarjeta
</label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{creditCards.map((card) => (
<MiniCard
key={card.id}
card={card}
selected={selectedCardId === card.id}
onClick={() => {
setSelectedCardId(card.id)
setShowPaymentForm(true)
}}
/>
))}
</div>
</div>
{/* Payment Form */}
{showPaymentForm && selectedCardId && (
<CardPaymentForm
cardId={selectedCardId}
onSubmit={handlePaymentSubmit}
onCancel={() => {
setShowPaymentForm(false)
setSelectedCardId('')
}}
/>
)}
</div>
{/* Recent Payments */}
<div className="rounded-xl bg-slate-800 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/20">
<Receipt className="h-4 w-4 text-amber-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Pagos del Mes</h3>
<p className="text-sm text-slate-400">
Total: {formatCurrency(
currentMonthPayments.reduce((sum, p) => sum + p.amount, 0)
)}
</p>
</div>
</div>
{currentMonthPayments.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-600 p-8 text-center">
<p className="text-sm text-slate-400">
No hay pagos registrados este mes
</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{currentMonthPayments
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.map((payment) => {
const card = getCardById(payment.cardId)
return (
<div
key={payment.id}
className="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-700/50 p-4"
>
<div className="flex items-center gap-3">
{card && (
<div
className="h-8 w-8 shrink-0 rounded-md"
style={{ backgroundColor: card.color }}
/>
)}
<div className="min-w-0">
<p className="truncate font-medium text-white">
{payment.description}
</p>
<p className="text-xs text-slate-400">
{card?.name} {formatShortDate(payment.date)}
{payment.installments && (
<span className="ml-2 text-amber-400">
Cuota {payment.installments.current}/{payment.installments.total}
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="font-semibold text-white">
{formatCurrency(payment.amount)}
</span>
<button
onClick={() => handleDeletePayment(payment.id)}
className="rounded p-1 text-slate-400 transition-colors hover:bg-rose-500/20 hover:text-rose-400"
aria-label="Eliminar pago"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Summary by Card */}
{currentMonthPayments.length > 0 && (
<div className="mt-6 border-t border-slate-700 pt-4">
<h4 className="mb-3 text-sm font-medium text-slate-300">
Resumen por tarjeta
</h4>
<div className="space-y-2">
{creditCards.map((card) => {
const total = getCardTotalPayments(card.id)
if (total === 0) return null
return (
<div
key={card.id}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: card.color }}
/>
<span className="text-slate-300">{card.name}</span>
</div>
<span className="font-medium text-white">
{formatCurrency(total)}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,242 @@
'use client'
import { useState } from 'react'
import { CreditCard } from '@/lib/types'
import { X, Check } from 'lucide-react'
interface CreditCardFormProps {
initialData?: Partial<CreditCard>
onSubmit: (data: Omit<CreditCard, 'id'>) => void
onCancel: () => void
}
const DEFAULT_COLOR = '#6366f1'
export function CreditCardForm({ initialData, onSubmit, onCancel }: CreditCardFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name ?? '',
lastFourDigits: initialData?.lastFourDigits ?? '',
closingDay: initialData?.closingDay ?? 1,
dueDay: initialData?.dueDay ?? 10,
currentBalance: initialData?.currentBalance ?? 0,
creditLimit: initialData?.creditLimit ?? 0,
color: initialData?.color ?? DEFAULT_COLOR,
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido'
}
if (!formData.lastFourDigits.trim()) {
newErrors.lastFourDigits = 'Los últimos 4 dígitos son requeridos'
} else if (!/^\d{4}$/.test(formData.lastFourDigits)) {
newErrors.lastFourDigits = 'Debe ser exactamente 4 dígitos numéricos'
}
if (formData.closingDay < 1 || formData.closingDay > 31) {
newErrors.closingDay = 'El día debe estar entre 1 y 31'
}
if (formData.dueDay < 1 || formData.dueDay > 31) {
newErrors.dueDay = 'El día debe estar entre 1 y 31'
}
if (formData.creditLimit <= 0) {
newErrors.creditLimit = 'El límite de crédito debe ser mayor a 0'
}
if (formData.currentBalance < 0) {
newErrors.currentBalance = 'El balance no puede ser negativo'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validateForm()) {
onSubmit(formData)
}
}
const updateField = (field: keyof typeof formData, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
const handleLastFourDigitsChange = (value: string) => {
// Only allow digits and max 4 characters
const digitsOnly = value.replace(/\D/g, '').slice(0, 4)
updateField('lastFourDigits', digitsOnly)
}
return (
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl bg-slate-800 p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">
{initialData ? 'Editar Tarjeta' : 'Nueva Tarjeta'}
</h3>
<button
type="button"
onClick={onCancel}
className="rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-700 hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* Card Name */}
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-slate-300">
Nombre de la tarjeta
</label>
<input
type="text"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="Ej: Visa Banco Galicia"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.name && <p className="mt-1 text-sm text-rose-400">{errors.name}</p>}
</div>
{/* Last 4 Digits */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Últimos 4 dígitos
</label>
<input
type="text"
inputMode="numeric"
value={formData.lastFourDigits}
onChange={(e) => handleLastFourDigitsChange(e.target.value)}
placeholder="1234"
maxLength={4}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.lastFourDigits && (
<p className="mt-1 text-sm text-rose-400">{errors.lastFourDigits}</p>
)}
</div>
{/* Color Picker */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">Color</label>
<div className="flex items-center gap-3">
<input
type="color"
value={formData.color}
onChange={(e) => updateField('color', e.target.value)}
className="h-10 w-20 cursor-pointer rounded-lg border border-slate-600 bg-slate-700"
/>
<span className="text-sm text-slate-400">{formData.color}</span>
</div>
</div>
{/* Closing Day */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Día de cierre
</label>
<input
type="number"
min={1}
max={31}
value={formData.closingDay}
onChange={(e) => updateField('closingDay', parseInt(e.target.value) || 1)}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.closingDay && (
<p className="mt-1 text-sm text-rose-400">{errors.closingDay}</p>
)}
</div>
{/* Due Day */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Día de vencimiento
</label>
<input
type="number"
min={1}
max={31}
value={formData.dueDay}
onChange={(e) => updateField('dueDay', parseInt(e.target.value) || 1)}
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.dueDay && <p className="mt-1 text-sm text-rose-400">{errors.dueDay}</p>}
</div>
{/* Credit Limit */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Límite de crédito
</label>
<input
type="number"
min={0}
step="0.01"
value={formData.creditLimit || ''}
onChange={(e) => updateField('creditLimit', parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.creditLimit && (
<p className="mt-1 text-sm text-rose-400">{errors.creditLimit}</p>
)}
</div>
{/* Current Balance */}
<div>
<label className="mb-1 block text-sm font-medium text-slate-300">
Balance actual
</label>
<input
type="number"
min={0}
step="0.01"
value={formData.currentBalance || ''}
onChange={(e) => updateField('currentBalance', parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
/>
{errors.currentBalance && (
<p className="mt-1 text-sm text-rose-400">{errors.currentBalance}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-700"
>
Cancelar
</button>
<button
type="submit"
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Check className="h-4 w-4" />
{initialData ? 'Guardar cambios' : 'Crear tarjeta'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { CreditCard } from '@/lib/types'
import { formatCurrency, getCardUtilization, getDaysUntil, calculateNextClosingDate, calculateNextDueDate } from '@/lib/utils'
import { Pencil, Trash2 } from 'lucide-react'
interface CreditCardWidgetProps {
card: CreditCard
onEdit: () => void
onDelete: () => void
}
export function CreditCardWidget({ card, onEdit, onDelete }: CreditCardWidgetProps) {
const utilization = getCardUtilization(card.currentBalance, card.creditLimit)
const nextClosing = calculateNextClosingDate(card.closingDay)
const nextDue = calculateNextDueDate(card.dueDay)
const daysUntilClosing = getDaysUntil(nextClosing)
const daysUntilDue = getDaysUntil(nextDue)
const getUtilizationColor = (util: number): string => {
if (util < 30) return 'bg-emerald-500'
if (util < 70) return 'bg-amber-500'
return 'bg-rose-500'
}
const getUtilizationTextColor = (util: number): string => {
if (util < 30) return 'text-emerald-400'
if (util < 70) return 'text-amber-400'
return 'text-rose-400'
}
return (
<div
className="relative overflow-hidden rounded-2xl p-6 text-white shadow-lg transition-transform hover:scale-[1.02]"
style={{
aspectRatio: '1.586',
background: `linear-gradient(135deg, ${card.color} 0%, ${adjustColor(card.color, -30)} 100%)`,
}}
>
{/* Decorative circles */}
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-white/10" />
<div className="absolute -bottom-12 -left-12 h-40 w-40 rounded-full bg-white/5" />
{/* Header with card name and actions */}
<div className="relative flex items-start justify-between">
<h3 className="text-lg font-semibold tracking-wide">{card.name}</h3>
<div className="flex gap-1">
<button
onClick={onEdit}
className="rounded-full p-1.5 transition-colors hover:bg-white/20"
aria-label="Editar tarjeta"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={onDelete}
className="rounded-full p-1.5 transition-colors hover:bg-white/20"
aria-label="Eliminar tarjeta"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Card number */}
<div className="relative mt-8">
<p className="font-mono text-2xl tracking-widest">
**** **** **** {card.lastFourDigits}
</p>
</div>
{/* Balance */}
<div className="relative mt-6">
<p className="text-sm text-white/70">Balance actual</p>
<p className="text-2xl font-bold">{formatCurrency(card.currentBalance)}</p>
</div>
{/* Utilization badge */}
<div className="relative mt-4 flex items-center gap-3">
<div className="flex items-center gap-2 rounded-full bg-black/30 px-3 py-1">
<div className={`h-2 w-2 rounded-full ${getUtilizationColor(utilization)}`} />
<span className={`text-sm font-medium ${getUtilizationTextColor(utilization)}`}>
{utilization.toFixed(0)}% usado
</span>
</div>
<span className="text-sm text-white/60">
de {formatCurrency(card.creditLimit)}
</span>
</div>
{/* Footer with closing and due dates */}
<div className="relative mt-4 flex justify-between text-xs text-white/70">
<div>
<span className="block">Cierre</span>
<span className="font-medium text-white">
{card.closingDay} ({daysUntilClosing === 0 ? 'hoy' : daysUntilClosing > 0 ? `en ${daysUntilClosing} días` : `hace ${Math.abs(daysUntilClosing)} días`})
</span>
</div>
<div className="text-right">
<span className="block">Vencimiento</span>
<span className="font-medium text-white">
{card.dueDay} ({daysUntilDue === 0 ? 'hoy' : daysUntilDue > 0 ? `en ${daysUntilDue} días` : `hace ${Math.abs(daysUntilDue)} días`})
</span>
</div>
</div>
</div>
)
}
/**
* Ajusta el brillo de un color hexadecimal
* @param color - Color en formato hex (#RRGGBB)
* @param amount - Cantidad a ajustar (negativo para oscurecer, positivo para aclarar)
* @returns Color ajustado en formato hex
*/
function adjustColor(color: string, amount: number): string {
const hex = color.replace('#', '')
const r = Math.max(0, Math.min(255, parseInt(hex.substring(0, 2), 16) + amount))
const g = Math.max(0, Math.min(255, parseInt(hex.substring(2, 4), 16) + amount))
const b = Math.max(0, Math.min(255, parseInt(hex.substring(4, 6), 16) + amount))
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}

View File

@@ -0,0 +1,43 @@
'use client'
import { CreditCard } from '@/lib/types'
import { Check } from 'lucide-react'
interface MiniCardProps {
card: CreditCard
selected?: boolean
onClick?: () => void
}
export function MiniCard({ card, selected = false, onClick }: MiniCardProps) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-all ${
selected
? 'border-indigo-500 bg-indigo-500/20 ring-2 ring-indigo-500/30'
: 'border-slate-600 bg-slate-800 hover:border-slate-500 hover:bg-slate-700'
}`}
>
{/* Color indicator */}
<div
className="h-10 w-10 shrink-0 rounded-lg shadow-inner"
style={{ backgroundColor: card.color }}
/>
{/* Card info */}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-white">{card.name}</p>
<p className="text-sm text-slate-400">**** {card.lastFourDigits}</p>
</div>
{/* Selected indicator */}
{selected && (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-500">
<Check className="h-4 w-4 text-white" />
</div>
)}
</button>
)
}

View File

@@ -0,0 +1,5 @@
export { CreditCardWidget } from './CreditCardWidget'
export { CreditCardForm } from './CreditCardForm'
export { CardPaymentForm } from './CardPaymentForm'
export { MiniCard } from './MiniCard'
export { CardSection } from './CardSection'

View File

@@ -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 (
<div className="flex flex-col gap-4 border-b border-slate-700 bg-slate-800/50 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white">
Dashboard
<span className="ml-2 text-lg font-normal text-slate-400">
{monthName} {currentYear}
</span>
</h1>
<p className="mt-1 text-sm capitalize text-slate-400">
{formattedDate}
</p>
</div>
{onRefresh && (
<button
onClick={onRefresh}
disabled={isRefreshing}
className={cn(
'inline-flex items-center gap-2 rounded-lg border border-slate-600',
'bg-slate-700 px-4 py-2 text-sm font-medium text-white',
'transition-colors hover:bg-slate-600',
'focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-slate-800',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<RefreshCw
className={cn('h-4 w-4', isRefreshing && 'animate-spin')}
/>
{isRefreshing ? 'Actualizando...' : 'Actualizar'}
</button>
)}
</div>
)
}

View File

@@ -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<string, string> = {
// 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<string, string> = {
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<string, number>()
// 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 (
<div className="flex h-64 items-center justify-center rounded-xl border border-slate-700 bg-slate-800">
<p className="text-slate-500">No hay gastos pendientes</p>
</div>
)
}
return (
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Distribución de Gastos
</h3>
<div className="flex flex-col gap-6 lg:flex-row">
{/* Gráfico de dona */}
<div className="h-64 w-full lg:w-1/2">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(value) =>
typeof value === 'number' ? formatCurrency(value) : value
}
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
color: '#fff',
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Leyenda */}
<div className="flex w-full flex-col justify-center gap-3 lg:w-1/2">
{data.map((item) => {
const percentage = total > 0 ? (item.value / total) * 100 : 0
return (
<div key={item.category} className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: item.color }}
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-300">
{item.name}
</span>
<span className="text-sm text-slate-400">
{percentage.toFixed(1)}%
</span>
</div>
<p className="text-xs text-slate-500">
{formatCurrency(item.value)}
</p>
</div>
</div>
)
})}
{/* Total */}
<div className="mt-4 border-t border-slate-700 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-400">Total</span>
<span className="font-mono text-lg font-bold text-emerald-400">
{formatCurrency(total)}
</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div className="relative overflow-hidden rounded-xl border border-slate-700 bg-slate-800 p-6 shadow-lg">
{/* Icono en esquina superior derecha */}
<div className={cn('absolute right-4 top-4', color)}>
<Icon className="h-10 w-10 opacity-80" />
</div>
{/* Contenido */}
<div className="relative">
{/* Título */}
<h3 className="text-sm font-medium text-slate-400">{title}</h3>
{/* Monto */}
<p className="mt-2 font-mono text-3xl font-bold text-emerald-400">
{formatCurrency(amount)}
</p>
{/* Subtítulo */}
{subtitle && (
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
)}
{/* Indicador de tendencia */}
{trend && (
<div className="mt-3 flex items-center gap-1.5">
{trend.isPositive ? (
<>
<TrendingUp className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium text-emerald-500">
+{trend.value}%
</span>
</>
) : (
<>
<TrendingDown className="h-4 w-4 text-rose-500" />
<span className="text-sm font-medium text-rose-500">
-{trend.value}%
</span>
</>
)}
<span className="text-sm text-slate-500">vs mes anterior</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -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 (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{actions.map((action) => {
const Icon = action.icon
return (
<button
key={action.label}
onClick={action.onClick}
className={`
group flex flex-col items-center gap-3 rounded-xl p-6
transition-all duration-200 ease-out
${action.color}
focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-slate-800
`}
>
<div className="rounded-full bg-white/20 p-4 transition-transform group-hover:scale-110">
<Icon className="h-8 w-8 text-white" />
</div>
<span className="font-medium text-white">{action.label}</span>
</button>
)
})}
</div>
)
}

View File

@@ -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 (
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Actividad Reciente
</h3>
<div className="flex h-32 items-center justify-center">
<p className="text-slate-500">No hay actividad reciente</p>
</div>
</div>
)
}
return (
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Actividad Reciente
</h3>
<div className="space-y-3">
{sortedActivities.map((activity) => {
const config = activityConfig[activity.type]
const Icon = config.icon
return (
<div
key={activity.id}
className="flex items-center gap-4 rounded-lg border border-slate-700/50 bg-slate-700/30 p-4 transition-colors hover:bg-slate-700/50"
>
{/* Icono */}
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full',
config.bgColor
)}
>
<Icon className={cn('h-5 w-5', config.color)} />
</div>
{/* Contenido */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<h4 className="truncate font-medium text-white">
{activity.title}
</h4>
<span
className={cn(
'shrink-0 font-mono font-medium',
activity.type === 'card_payment'
? 'text-emerald-400'
: 'text-rose-400'
)}
>
{activity.type === 'card_payment' ? '+' : '-'}
{formatCurrency(activity.amount)}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-sm text-slate-400">
<span className={cn('text-xs', config.color)}>
{config.label}
</span>
<span></span>
<span>{formatShortDate(activity.date)}</span>
{activity.description && (
<>
<span></span>
<span className="truncate">{activity.description}</span>
</>
)}
</div>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -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 (
<div className="space-y-6">
{/* Grid de métricas */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
title="Deudas Pendientes"
amount={totalPendingDebts}
subtitle={`${fixedDebts.filter((d) => !d.isPaid).length + variableDebts.filter((d) => !d.isPaid).length} pagos pendientes`}
icon={Wallet}
color="text-rose-400"
/>
<MetricCard
title="Balance en Tarjetas"
amount={totalCardBalance}
subtitle={`${creditCards.length} tarjetas activas`}
icon={CreditCard}
color="text-blue-400"
/>
<MetricCard
title="Presupuesto Disponible"
amount={availableBudget}
subtitle={
currentBudget
? `de ${currentBudget.totalIncome.toLocaleString('es-AR', {
style: 'currency',
currency: 'ARS',
})} ingresos`
: 'Sin presupuesto definido'
}
icon={PiggyBank}
color="text-emerald-400"
/>
<MetricCard
title="Meta de Ahorro"
amount={projectedSavings}
subtitle={
savingsGoal > 0
? `${((projectedSavings / savingsGoal) * 100).toFixed(0)}% de la meta`
: 'Sin meta definida'
}
icon={PiggyBank}
color="text-violet-400"
/>
</div>
{/* Gráfico y alertas */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Gráfico de distribución */}
<ExpenseChart fixedDebts={fixedDebts} variableDebts={variableDebts} />
{/* Alertas destacadas */}
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-white">
Alertas Destacadas
</h3>
{unreadAlerts.length === 0 ? (
<div className="flex h-48 items-center justify-center">
<p className="text-slate-500">No hay alertas pendientes</p>
</div>
) : (
<div className="space-y-3">
{unreadAlerts.map((alert) => (
<div
key={alert.id}
className={cn(
'flex items-start gap-3 rounded-lg border p-4',
severityColors[alert.severity]
)}
>
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
<div>
<h4 className="font-medium">{alert.title}</h4>
<p className="mt-1 text-sm opacity-90">{alert.message}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,140 @@
'use client'
import { FixedDebt, VariableDebt } from '@/lib/types'
import { formatCurrency, formatShortDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { Pencil, Trash2, Check } from 'lucide-react'
interface DebtCardProps {
debt: FixedDebt | VariableDebt
type: 'fixed' | 'variable'
onTogglePaid: () => void
onEdit: () => void
onDelete: () => void
}
const fixedCategoryColors: Record<string, string> = {
housing: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
services: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
subscription: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
other: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
}
const variableCategoryColors: Record<string, string> = {
shopping: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
food: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
entertainment: 'bg-indigo-500/20 text-indigo-400 border-indigo-500/30',
health: 'bg-red-500/20 text-red-400 border-red-500/30',
transport: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
other: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
}
const categoryLabels: Record<string, string> = {
housing: 'Vivienda',
services: 'Servicios',
subscription: 'Suscripción',
shopping: 'Compras',
food: 'Comida',
entertainment: 'Entretenimiento',
health: 'Salud',
transport: 'Transporte',
other: 'Otro',
}
export function DebtCard({ debt, type, onTogglePaid, onEdit, onDelete }: DebtCardProps) {
const isFixed = type === 'fixed'
const categoryColors = isFixed ? fixedCategoryColors : variableCategoryColors
const categoryColor = categoryColors[debt.category] || categoryColors.other
const getDueInfo = () => {
if (isFixed) {
const fixedDebt = debt as FixedDebt
return `Vence día ${fixedDebt.dueDay}`
} else {
const variableDebt = debt as VariableDebt
return formatShortDate(variableDebt.date)
}
}
return (
<div
className={cn(
'group relative bg-slate-800 border border-slate-700/50 rounded-lg p-4',
'transition-all duration-200 hover:border-slate-600',
debt.isPaid && 'opacity-60'
)}
>
<div className="flex items-start gap-3">
{/* Checkbox */}
<button
onClick={onTogglePaid}
className={cn(
'mt-1 w-5 h-5 rounded border-2 flex items-center justify-center',
'transition-colors duration-200',
debt.isPaid
? 'bg-emerald-500 border-emerald-500'
: 'border-slate-500 hover:border-emerald-400'
)}
aria-label={debt.isPaid ? 'Marcar como no pagada' : 'Marcar como pagada'}
>
{debt.isPaid && <Check className="w-3 h-3 text-white" />}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<h3
className={cn(
'text-white font-medium truncate',
debt.isPaid && 'line-through text-slate-400'
)}
>
{debt.name}
</h3>
<p className="text-slate-400 text-sm mt-0.5">{getDueInfo()}</p>
</div>
<span className="font-mono text-emerald-400 font-semibold whitespace-nowrap">
{formatCurrency(debt.amount)}
</span>
</div>
<div className="flex items-center justify-between mt-3">
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border',
categoryColor
)}
>
{categoryLabels[debt.category] || debt.category}
</span>
{isFixed && (debt as FixedDebt).isAutoDebit && (
<span className="text-xs text-slate-500">
Débito automático
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onEdit}
className="p-1.5 text-slate-400 hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
aria-label="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={onDelete}
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
aria-label="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,224 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { FixedDebt, VariableDebt } from '@/lib/types'
import { DebtCard } from './DebtCard'
import { FixedDebtForm } from './FixedDebtForm'
import { VariableDebtForm } from './VariableDebtForm'
import { Plus, Wallet } from 'lucide-react'
import { cn, formatCurrency, calculateTotalFixedDebts, calculateTotalVariableDebts } from '@/lib/utils'
type DebtType = 'fixed' | 'variable'
export function DebtSection() {
const [activeTab, setActiveTab] = useState<DebtType>('fixed')
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingDebt, setEditingDebt] = useState<FixedDebt | VariableDebt | null>(null)
const {
fixedDebts,
variableDebts,
addFixedDebt,
updateFixedDebt,
deleteFixedDebt,
toggleFixedDebtPaid,
addVariableDebt,
updateVariableDebt,
deleteVariableDebt,
toggleVariableDebtPaid,
} = useFinanzasStore()
const currentDebts = activeTab === 'fixed' ? fixedDebts : variableDebts
const totalUnpaid = activeTab === 'fixed'
? calculateTotalFixedDebts(fixedDebts)
: calculateTotalVariableDebts(variableDebts)
const handleAdd = () => {
setEditingDebt(null)
setIsModalOpen(true)
}
const handleEdit = (debt: FixedDebt | VariableDebt) => {
setEditingDebt(debt)
setIsModalOpen(true)
}
const handleCloseModal = () => {
setIsModalOpen(false)
setEditingDebt(null)
}
const handleSubmitFixed = (data: Omit<FixedDebt, 'id' | 'isPaid'>) => {
if (editingDebt?.id) {
updateFixedDebt(editingDebt.id, data)
} else {
addFixedDebt({ ...data, isPaid: false })
}
handleCloseModal()
}
const handleSubmitVariable = (data: Omit<VariableDebt, 'id' | 'isPaid'>) => {
if (editingDebt?.id) {
updateVariableDebt(editingDebt.id, data)
} else {
addVariableDebt({ ...data, isPaid: false })
}
handleCloseModal()
}
const handleDelete = (debt: FixedDebt | VariableDebt) => {
if (confirm('¿Estás seguro de que deseas eliminar esta deuda?')) {
if (activeTab === 'fixed') {
deleteFixedDebt(debt.id)
} else {
deleteVariableDebt(debt.id)
}
}
}
const handleTogglePaid = (debt: FixedDebt | VariableDebt) => {
if (activeTab === 'fixed') {
toggleFixedDebtPaid(debt.id)
} else {
toggleVariableDebtPaid(debt.id)
}
}
const paidCount = currentDebts.filter(d => d.isPaid).length
const unpaidCount = currentDebts.filter(d => !d.isPaid).length
return (
<div className="bg-slate-900 min-h-screen p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Deudas</h1>
<p className="text-slate-400 text-sm mt-1">
Gestiona tus gastos fijos y variables
</p>
</div>
<button
onClick={handleAdd}
className={cn(
'flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
'hover:bg-blue-500 transition-colors'
)}
>
<Plus className="w-4 h-4" />
Agregar
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<p className="text-slate-400 text-sm">Total pendiente</p>
<p className="text-xl font-mono font-semibold text-emerald-400 mt-1">
{formatCurrency(totalUnpaid)}
</p>
</div>
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<p className="text-slate-400 text-sm">Pagadas</p>
<p className="text-xl font-semibold text-blue-400 mt-1">
{paidCount}
</p>
</div>
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
<p className="text-slate-400 text-sm">Pendientes</p>
<p className="text-xl font-semibold text-orange-400 mt-1">
{unpaidCount}
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab('fixed')}
className={cn(
'px-4 py-2 rounded-lg font-medium transition-colors',
activeTab === 'fixed'
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'
)}
>
Fijas ({fixedDebts.length})
</button>
<button
onClick={() => setActiveTab('variable')}
className={cn(
'px-4 py-2 rounded-lg font-medium transition-colors',
activeTab === 'variable'
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'
)}
>
Variables ({variableDebts.length})
</button>
</div>
{/* Debt List */}
<div className="space-y-3">
{currentDebts.length === 0 ? (
<div className="text-center py-16 bg-slate-800/50 border border-slate-700/50 rounded-lg">
<Wallet className="w-12 h-12 text-slate-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-300">
No hay deudas {activeTab === 'fixed' ? 'fijas' : 'variables'}
</h3>
<p className="text-slate-500 mt-2">
Haz clic en "Agregar" para crear una nueva deuda
</p>
</div>
) : (
currentDebts.map((debt) => (
<DebtCard
key={debt.id}
debt={debt}
type={activeTab}
onTogglePaid={() => handleTogglePaid(debt)}
onEdit={() => handleEdit(debt)}
onDelete={() => handleDelete(debt)}
/>
))
)}
</div>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={handleCloseModal}
/>
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-xl font-bold text-white mb-4">
{editingDebt
? 'Editar deuda'
: activeTab === 'fixed'
? 'Nueva deuda fija'
: 'Nueva deuda variable'}
</h2>
{activeTab === 'fixed' ? (
<FixedDebtForm
initialData={editingDebt as Partial<FixedDebt> | undefined}
onSubmit={handleSubmitFixed}
onCancel={handleCloseModal}
/>
) : (
<VariableDebtForm
initialData={editingDebt as Partial<VariableDebt> | undefined}
onSubmit={handleSubmitVariable}
onCancel={handleCloseModal}
/>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,212 @@
'use client'
import { useState } from 'react'
import { FixedDebt } from '@/lib/types'
import { cn } from '@/lib/utils'
interface FixedDebtFormProps {
initialData?: Partial<FixedDebt>
onSubmit: (data: Omit<FixedDebt, 'id' | 'isPaid'>) => void
onCancel: () => void
}
const categories = [
{ value: 'housing', label: 'Vivienda' },
{ value: 'services', label: 'Servicios' },
{ value: 'subscription', label: 'Suscripción' },
{ value: 'other', label: 'Otro' },
] as const
export function FixedDebtForm({ initialData, onSubmit, onCancel }: FixedDebtFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
amount: initialData?.amount || 0,
dueDay: initialData?.dueDay || 1,
category: initialData?.category || 'other',
isAutoDebit: initialData?.isAutoDebit || false,
notes: initialData?.notes || '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido'
}
if (formData.amount <= 0) {
newErrors.amount = 'El monto debe ser mayor a 0'
}
if (formData.dueDay < 1 || formData.dueDay > 31) {
newErrors.dueDay = 'El día debe estar entre 1 y 31'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
onSubmit(formData)
}
}
const updateField = <K extends keyof typeof formData>(
field: K,
value: typeof formData[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-300 mb-1">
Nombre <span className="text-red-400">*</span>
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.name ? 'border-red-500' : 'border-slate-600'
)}
placeholder="Ej: Alquiler, Internet, etc."
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="amount" className="block text-sm font-medium text-slate-300 mb-1">
Monto <span className="text-red-400">*</span>
</label>
<input
type="number"
id="amount"
min="0"
step="0.01"
value={formData.amount || ''}
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.amount ? 'border-red-500' : 'border-slate-600'
)}
placeholder="0.00"
/>
{errors.amount && <p className="mt-1 text-sm text-red-400">{errors.amount}</p>}
</div>
<div>
<label htmlFor="dueDay" className="block text-sm font-medium text-slate-300 mb-1">
Día de vencimiento <span className="text-red-400">*</span>
</label>
<input
type="number"
id="dueDay"
min="1"
max="31"
value={formData.dueDay}
onChange={(e) => updateField('dueDay', parseInt(e.target.value) || 1)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.dueDay ? 'border-red-500' : 'border-slate-600'
)}
placeholder="1"
/>
{errors.dueDay && <p className="mt-1 text-sm text-red-400">{errors.dueDay}</p>}
</div>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium text-slate-300 mb-1">
Categoría
</label>
<select
id="category"
value={formData.category}
onChange={(e) => updateField('category', e.target.value as FixedDebt['category'])}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500'
)}
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isAutoDebit"
checked={formData.isAutoDebit}
onChange={(e) => updateField('isAutoDebit', e.target.checked)}
className="w-4 h-4 rounded border-slate-600 bg-slate-800 text-blue-500 focus:ring-blue-500/50"
/>
<label htmlFor="isAutoDebit" className="text-sm text-slate-300">
Tiene débito automático
</label>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-slate-300 mb-1">
Notas <span className="text-slate-500">(opcional)</span>
</label>
<textarea
id="notes"
rows={3}
value={formData.notes}
onChange={(e) => updateField('notes', e.target.value)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
'resize-none'
)}
placeholder="Notas adicionales..."
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className={cn(
'flex-1 px-4 py-2 bg-slate-700 text-slate-200 rounded-lg font-medium',
'hover:bg-slate-600 transition-colors'
)}
>
Cancelar
</button>
<button
type="submit"
className={cn(
'flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
'hover:bg-blue-500 transition-colors'
)}
>
{initialData?.id ? 'Guardar cambios' : 'Agregar deuda'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,197 @@
'use client'
import { useState } from 'react'
import { VariableDebt } from '@/lib/types'
import { cn } from '@/lib/utils'
interface VariableDebtFormProps {
initialData?: Partial<VariableDebt>
onSubmit: (data: Omit<VariableDebt, 'id' | 'isPaid'>) => void
onCancel: () => void
}
const categories = [
{ value: 'shopping', label: 'Compras' },
{ value: 'food', label: 'Comida' },
{ value: 'entertainment', label: 'Entretenimiento' },
{ value: 'health', label: 'Salud' },
{ value: 'transport', label: 'Transporte' },
{ value: 'other', label: 'Otro' },
] as const
export function VariableDebtForm({ initialData, onSubmit, onCancel }: VariableDebtFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
amount: initialData?.amount || 0,
date: initialData?.date || new Date().toISOString().split('T')[0],
category: initialData?.category || 'other',
notes: initialData?.notes || '',
})
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido'
}
if (formData.amount <= 0) {
newErrors.amount = 'El monto debe ser mayor a 0'
}
if (!formData.date) {
newErrors.date = 'La fecha es requerida'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
onSubmit(formData)
}
}
const updateField = <K extends keyof typeof formData>(
field: K,
value: typeof formData[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-300 mb-1">
Nombre <span className="text-red-400">*</span>
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.name ? 'border-red-500' : 'border-slate-600'
)}
placeholder="Ej: Supermercado, Cena, etc."
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="amount" className="block text-sm font-medium text-slate-300 mb-1">
Monto <span className="text-red-400">*</span>
</label>
<input
type="number"
id="amount"
min="0"
step="0.01"
value={formData.amount || ''}
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.amount ? 'border-red-500' : 'border-slate-600'
)}
placeholder="0.00"
/>
{errors.amount && <p className="mt-1 text-sm text-red-400">{errors.amount}</p>}
</div>
<div>
<label htmlFor="date" className="block text-sm font-medium text-slate-300 mb-1">
Fecha <span className="text-red-400">*</span>
</label>
<input
type="date"
id="date"
value={formData.date}
onChange={(e) => updateField('date', e.target.value)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
errors.date ? 'border-red-500' : 'border-slate-600'
)}
/>
{errors.date && <p className="mt-1 text-sm text-red-400">{errors.date}</p>}
</div>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium text-slate-300 mb-1">
Categoría
</label>
<select
id="category"
value={formData.category}
onChange={(e) => updateField('category', e.target.value as VariableDebt['category'])}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500'
)}
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-slate-300 mb-1">
Notas <span className="text-slate-500">(opcional)</span>
</label>
<textarea
id="notes"
rows={3}
value={formData.notes}
onChange={(e) => updateField('notes', e.target.value)}
className={cn(
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
'resize-none'
)}
placeholder="Notas adicionales..."
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className={cn(
'flex-1 px-4 py-2 bg-slate-700 text-slate-200 rounded-lg font-medium',
'hover:bg-slate-600 transition-colors'
)}
>
Cancelar
</button>
<button
type="submit"
className={cn(
'flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
'hover:bg-blue-500 transition-colors'
)}
>
{initialData?.id ? 'Guardar cambios' : 'Agregar deuda'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,4 @@
export { DebtCard } from './DebtCard';
export { DebtSection } from './DebtSection';
export { FixedDebtForm } from './FixedDebtForm';
export { VariableDebtForm } from './VariableDebtForm';

View File

@@ -0,0 +1,60 @@
'use client'
import { ReactNode, useEffect } from 'react'
import { Sidebar, Header, MobileNav } from '@/components/layout'
import { useSidebar } from '@/app/providers'
import { useAlerts } from '@/components/alerts'
interface DashboardLayoutProps {
children: ReactNode
title: string
}
export function DashboardLayout({ children, title }: DashboardLayoutProps) {
const { isOpen, toggle, close, open } = useSidebar()
const { unreadCount } = useAlerts()
// Ensure sidebar is open on desktop mount
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 1024) {
open()
} else {
close()
}
}
// Initial check
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [open, close])
return (
<div className="flex min-h-screen bg-slate-950">
{/* Sidebar */}
<Sidebar
isOpen={isOpen}
onClose={close}
unreadAlertsCount={unreadCount}
/>
{/* Main content wrapper */}
<div className="flex flex-1 flex-col lg:ml-0 min-w-0">
{/* Header */}
<Header onMenuClick={toggle} title={title} />
{/* Page content */}
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
<div className="mx-auto max-w-7xl h-full">
{children}
</div>
</main>
</div>
{/* Mobile Navigation */}
<MobileNav unreadAlertsCount={unreadCount} />
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client';
import { Menu } from 'lucide-react';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { Logo } from './Logo';
interface HeaderProps {
onMenuClick: () => void;
title: string;
}
export function Header({ onMenuClick, title }: HeaderProps) {
const currentDate = format(new Date(), "EEEE, d 'de' MMMM 'de' yyyy", {
locale: es,
});
// Capitalizar primera letra
const formattedDate =
currentDate.charAt(0).toUpperCase() + currentDate.slice(1);
return (
<header className="sticky top-0 z-30 bg-slate-900/95 backdrop-blur-sm border-b border-slate-800">
<div className="flex items-center justify-between h-16 px-4 md:px-6">
{/* Left section */}
<div className="flex items-center gap-4">
<button
onClick={onMenuClick}
className="lg:hidden p-2 -ml-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors"
aria-label="Abrir menú"
>
<Menu className="w-6 h-6" />
</button>
<div className="flex items-center gap-3">
<div className="lg:hidden">
<Logo size="sm" showText={false} />
</div>
<h1 className="text-lg md:text-xl font-semibold text-slate-100">
{title}
</h1>
</div>
</div>
{/* Right section */}
<div className="flex items-center gap-4">
<div className="hidden md:flex items-center gap-2">
<Logo size="sm" showText />
</div>
<time className="text-sm text-slate-400 hidden sm:block">
{formattedDate}
</time>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,36 @@
import { Wallet } from 'lucide-react';
interface LogoProps {
size?: 'sm' | 'md' | 'lg';
showText?: boolean;
}
const sizeMap = {
sm: {
icon: 24,
text: 'text-lg',
},
md: {
icon: 32,
text: 'text-xl',
},
lg: {
icon: 40,
text: 'text-2xl',
},
};
export function Logo({ size = 'md', showText = true }: LogoProps) {
const { icon, text } = sizeMap[size];
return (
<div className="flex items-center gap-2">
<div className="flex items-center justify-center">
<Wallet className="text-emerald-500" size={icon} strokeWidth={2} />
</div>
{showText && (
<span className={`font-bold text-slate-100 ${text}`}>Finanzas</span>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import {
LayoutDashboard,
Wallet,
CreditCard,
PiggyBank,
Bell,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface MobileNavProps {
unreadAlertsCount?: number;
}
const navigationItems = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Deudas', href: '/debts', icon: Wallet },
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
];
export function MobileNav({ unreadAlertsCount = 0 }: MobileNavProps) {
const pathname = usePathname();
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
return (
<nav className="fixed bottom-0 left-0 right-0 z-40 bg-slate-900 border-t border-slate-800 lg:hidden">
<ul className="flex items-center justify-around h-16">
{navigationItems.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
<li key={item.name} className="flex-1">
<Link
href={item.href}
className={`
flex flex-col items-center justify-center gap-1 py-2
transition-colors relative
${
active
? 'text-emerald-500'
: 'text-slate-400 hover:text-slate-300'
}
`}
>
<div className="relative">
<Icon className="w-6 h-6" />
{item.hasBadge && unreadAlertsCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-semibold bg-red-500 text-white rounded-full">
{unreadAlertsCount > 99 ? '99+' : unreadAlertsCount}
</span>
)}
</div>
<span className="text-[10px] font-medium">{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,18 @@
import { ReactNode } from 'react';
interface PageContainerProps {
children: ReactNode;
title: string;
}
export function PageContainer({ children, title }: PageContainerProps) {
return (
<main className="min-h-screen bg-slate-950">
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
<div className="space-y-6">
{children}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,35 @@
import { ReactNode } from 'react';
interface SectionAction {
label: string;
onClick: () => void;
}
interface SectionProps {
title: string;
children: ReactNode;
action?: SectionAction;
}
export function Section({ title, children, action }: SectionProps) {
return (
<section className="bg-slate-900 rounded-lg border border-slate-800">
<div className="flex items-center justify-between px-4 py-3 md:px-6 md:py-4 border-b border-slate-800">
<h2 className="text-base md:text-lg font-semibold text-slate-100">
{title}
</h2>
{action && (
<button
onClick={action.onClick}
className="px-3 py-1.5 text-sm font-medium text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition-colors"
>
{action.label}
</button>
)}
</div>
<div className="p-4 md:p-6">
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import {
LayoutDashboard,
Wallet,
CreditCard,
PiggyBank,
Bell,
Lightbulb,
Settings,
TrendingUp,
X,
LogOut,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { Logo } from './Logo';
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
unreadAlertsCount?: number;
}
const navigationItems = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Ingresos', href: '/incomes', icon: TrendingUp },
{ name: 'Deudas', href: '/debts', icon: Wallet },
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
{ name: 'Servicios', href: '/services', icon: Lightbulb },
{ name: 'Configuración', href: '/settings', icon: Settings },
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
];
export function Sidebar({
isOpen,
onClose,
unreadAlertsCount = 0,
}: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
// Clear local data to avoid leaking to other users
localStorage.removeItem('finanzas-storage');
router.push('/login');
router.refresh();
} catch (e) {
console.error('Logout failed', e);
}
};
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={onClose}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`
fixed top-0 left-0 z-50 h-full w-64 bg-slate-900 border-r border-slate-800
transform transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:static lg:h-screen
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800">
<Logo size="md" showText />
<button
onClick={onClose}
className="lg:hidden p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors"
aria-label="Cerrar menú"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3">
<ul className="space-y-1">
{navigationItems.map((item) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
<li key={item.name}>
<Link
href={item.href}
onClick={onClose}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors relative
${active
? 'bg-slate-800 text-emerald-400 border-l-2 border-emerald-500'
: 'text-slate-300 hover:bg-slate-800 hover:text-slate-100'
}
`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="flex-1">{item.name}</span>
{item.hasBadge && unreadAlertsCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold bg-red-500 text-white rounded-full">
{unreadAlertsCount > 99 ? '99+' : unreadAlertsCount}
</span>
)}
</Link>
</li>
);
})}
</ul>
<div className="mt-auto px-3 py-3 border-t border-slate-800">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-red-500/10 hover:text-red-400 transition-colors"
>
<LogOut className="w-5 h-5 flex-shrink-0" />
<span className="flex-1 text-left">Cerrar Sesión</span>
</button>
</div>
</nav>
{/* Footer */}
<div className="p-4 border-t border-slate-800">
<p className="text-xs text-slate-500 text-center">
Finanzas v{process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0'}
</p>
</div>
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,6 @@
export { Sidebar } from './Sidebar';
export { Header } from './Header';
export { MobileNav } from './MobileNav';
export { Logo } from './Logo';
export { PageContainer } from './PageContainer';
export { Section } from './Section';

View File

@@ -0,0 +1,196 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { X, CreditCard, Calendar, DollarSign, Palette } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AddCardModalProps {
isOpen: boolean
onClose: () => void
}
const COLORS = [
{ name: 'Slate', value: '#64748b' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Cyan', value: '#06b6d4' },
{ name: 'Emerald', value: '#10b981' },
{ name: 'Violet', value: '#8b5cf6' },
{ name: 'Rose', value: '#f43f5e' },
{ name: 'Amber', value: '#f59e0b' },
]
export function AddCardModal({ isOpen, onClose }: AddCardModalProps) {
const addCreditCard = useFinanzasStore((state) => state.addCreditCard)
const [name, setName] = useState('')
const [lastFour, setLastFour] = useState('')
const [limit, setLimit] = useState('')
const [closingDay, setClosingDay] = useState('')
const [dueDay, setDueDay] = useState('')
const [selectedColor, setSelectedColor] = useState(COLORS[1].value)
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !limit || !closingDay || !dueDay) return
addCreditCard({
name,
lastFourDigits: lastFour || '****',
closingDay: parseInt(closingDay),
dueDay: parseInt(dueDay),
currentBalance: 0,
creditLimit: parseFloat(limit),
color: selectedColor
})
// Reset
setName('')
setLastFour('')
setLimit('')
setClosingDay('')
setDueDay('')
setSelectedColor(COLORS[1].value)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
<CreditCard className="text-cyan-500" /> Nueva Tarjeta
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Name & Last 4 */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Nombre Banco / Tarjeta</label>
<input
type="text"
placeholder="Ej: Visa Santander"
value={name}
onChange={(e) => setName(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-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
autoFocus
/>
</div>
<div className="col-span-1 space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Ult. 4 Dig.</label>
<input
type="text"
maxLength={4}
placeholder="1234"
value={lastFour}
onChange={(e) => setLastFour(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-cyan-500/50 focus:border-cyan-500 text-white outline-none text-center tracking-widest"
/>
</div>
</div>
{/* Limit */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Límite de Crédito</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
<input
type="number"
step="0.01"
placeholder="0.00"
value={limit}
onChange={(e) => setLimit(e.target.value)}
className="w-full pl-8 pr-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 text-lg font-mono outline-none"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Closing Day */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Día Cierre</label>
<div className="relative">
<input
type="number"
min="1"
max="31"
placeholder="20"
value={closingDay}
onChange={(e) => setClosingDay(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-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
</div>
</div>
{/* Due Day */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Día Vencimiento</label>
<div className="relative">
<input
type="number"
min="1"
max="31"
placeholder="5"
value={dueDay}
onChange={(e) => setDueDay(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-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
</div>
</div>
</div>
{/* Color Picker */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Palette size={12} /> Color de Tarjeta
</label>
<div className="flex gap-3 overflow-x-auto pb-2">
{COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all",
selectedColor === color.value
? "border-white scale-110 shadow-lg"
: "border-transparent opacity-70 hover:opacity-100 hover:scale-105"
)}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
>
Crear Tarjeta
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,232 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { v4 as uuidv4 } from 'uuid'
import { X, Calendar, DollarSign, Tag, FileText, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AddDebtModalProps {
isOpen: boolean
onClose: () => void
}
type DebtType = 'fixed' | 'variable'
export function AddDebtModal({ isOpen, onClose }: AddDebtModalProps) {
const [activeTab, setActiveTab] = useState<DebtType>('variable')
const [name, setName] = useState('')
const [amount, setAmount] = useState('')
const [dateStr, setDateStr] = useState(new Date().toISOString().split('T')[0]) // For variable: YYYY-MM-DD
const [dueDay, setDueDay] = useState('1') // For fixed: 1-31
const [categoryFixed, setCategoryFixed] = useState('housing')
const [categoryVariable, setCategoryVariable] = useState('shopping')
const [isAutoDebit, setIsAutoDebit] = useState(false)
const [notes, setNotes] = useState('')
const addFixedDebt = useFinanzasStore((state) => state.addFixedDebt)
const addVariableDebt = useFinanzasStore((state) => state.addVariableDebt)
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !amount) return
const numAmount = parseFloat(amount)
if (isNaN(numAmount)) return
if (activeTab === 'fixed') {
addFixedDebt({
name,
amount: numAmount,
dueDay: parseInt(dueDay),
category: categoryFixed as any,
isAutoDebit,
isPaid: false,
notes: notes || undefined
})
} else {
addVariableDebt({
name,
amount: numAmount,
date: new Date(dateStr).toISOString(),
category: categoryVariable as any,
isPaid: false,
notes: notes || undefined
})
}
// Reset and Close
setName('')
setAmount('')
setNotes('')
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h2 className="text-xl font-semibold text-white">Agregar Gasto / Deuda</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="flex p-1 mx-6 mt-6 bg-slate-800/50 rounded-lg">
<button
onClick={() => setActiveTab('variable')}
className={cn(
"flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200",
activeTab === 'variable' ? "bg-cyan-500 text-white shadow-lg" : "text-slate-400 hover:text-white"
)}
>
Variable (Único)
</button>
<button
onClick={() => setActiveTab('fixed')}
className={cn(
"flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200",
activeTab === 'fixed' ? "bg-cyan-500 text-white shadow-lg" : "text-slate-400 hover:text-white"
)}
>
Fijo (Recurrente)
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Amount Input */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
<input
type="number"
step="0.01"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full pl-8 pr-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 text-lg font-mono outline-none transition-all placeholder:text-slate-600"
required
autoFocus
/>
</div>
</div>
{/* Name Input */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Descripción</label>
<input
type="text"
placeholder="Ej: Supermercado Coto, Netflix, Alquiler"
value={name}
onChange={(e) => setName(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-cyan-500/50 focus:border-cyan-500 text-white outline-none transition-all placeholder:text-slate-600"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Category Select */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<Tag size={12} /> Categoría
</label>
<select
value={activeTab === 'fixed' ? categoryFixed : categoryVariable}
onChange={(e) => activeTab === 'fixed' ? setCategoryFixed(e.target.value) : setCategoryVariable(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-cyan-500/50 focus:border-cyan-500 text-white outline-none appearance-none cursor-pointer"
>
{activeTab === 'fixed' ? (
<>
<option value="housing">Vivienda</option>
<option value="services">Servicios</option>
<option value="subscription">Suscripciones</option>
<option value="other">Otro</option>
</>
) : (
<>
<option value="food">Comida / Super</option>
<option value="shopping">Compras</option>
<option value="transport">Transporte</option>
<option value="health">Salud</option>
<option value="entertainment">Entretenimiento</option>
<option value="other">Otro</option>
</>
)}
</select>
</div>
{/* Date/DueDay Input */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<Calendar size={12} /> {activeTab === 'fixed' ? 'Día Vencimiento' : 'Fecha'}
</label>
{activeTab === 'fixed' ? (
<div className="relative">
<input
type="number"
min="1"
max="31"
value={dueDay}
onChange={(e) => setDueDay(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-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
</div>
) : (
<input
type="date"
value={dateStr}
onChange={(e) => setDateStr(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-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
required
/>
)}
</div>
</div>
{activeTab === 'fixed' && (
<div className="flex items-center gap-2 px-4 py-3 bg-slate-800/30 rounded-lg cursor-pointer" onClick={() => setIsAutoDebit(!isAutoDebit)}>
<div className={cn("w-5 h-5 rounded border flex items-center justify-center transition-colors", isAutoDebit ? "bg-cyan-500 border-cyan-500" : "border-slate-600 bg-transparent")}>
{isAutoDebit && <CheckCircle2 size={14} className="text-white" />}
</div>
<span className="text-sm text-slate-300 select-none">Débito Automático</span>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<FileText size={12} /> Notas (Opcional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Detalles adicionales..."
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none min-h-[80px] text-sm resize-none placeholder:text-slate-600"
/>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
>
Agregar {activeTab === 'fixed' ? 'Gasto Fijo' : 'Gasto'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { X, CreditCard, DollarSign, Calendar, FileText, Layers } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AddPaymentModalProps {
isOpen: boolean
onClose: () => void
}
export function AddPaymentModal({ isOpen, onClose }: AddPaymentModalProps) {
const cards = useFinanzasStore((state) => state.creditCards)
const addCardPayment = useFinanzasStore((state) => state.addCardPayment)
const [selectedCardId, setSelectedCardId] = useState(cards[0]?.id || '')
const [description, setDescription] = useState('')
const [amount, setAmount] = useState('')
const [dateStr, setDateStr] = useState(new Date().toISOString().split('T')[0])
const [hasInstallments, setHasInstallments] = useState(false)
const [installments, setInstallments] = useState('1')
const [totalInstallments, setTotalInstallments] = useState('12')
if (!isOpen) return null
// Ensure card selection if cards exist
if (!selectedCardId && cards.length > 0) {
setSelectedCardId(cards[0].id)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!description || !amount || !selectedCardId) return
addCardPayment({
cardId: selectedCardId,
amount: parseFloat(amount),
date: new Date(dateStr).toISOString(),
description,
installments: hasInstallments ? {
current: parseInt(installments),
total: parseInt(totalInstallments)
} : undefined
})
// Reset
setDescription('')
setAmount('')
setHasInstallments(false)
setInstallments('1')
setTotalInstallments('12')
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h2 className="text-xl font-semibold text-white">Registrar Consumo / Pago</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{cards.length === 0 ? (
<div className="p-8 text-center space-y-4">
<CreditCard className="mx-auto text-slate-600 mb-2" size={48} />
<h3 className="text-lg font-medium text-white">No tienes tarjetas registradas</h3>
<p className="text-slate-400">Debes agregar una tarjeta antes de registrar pagos.</p>
<button
onClick={onClose}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg transition"
>
Entendido
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Card Selection */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Tarjeta</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[120px] overflow-y-auto pr-1">
{cards.map((card) => (
<div
key={card.id}
onClick={() => setSelectedCardId(card.id)}
className={cn(
"cursor-pointer p-3 rounded-lg border flex items-center gap-3 transition-all",
selectedCardId === card.id
? "border-cyan-500 bg-cyan-500/10 ring-1 ring-cyan-500"
: "border-slate-800 bg-slate-950 hover:border-slate-700"
)}
>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: card.color }} />
<div className="flex flex-col truncate">
<span className="text-sm font-medium text-white truncate">{card.name}</span>
<span className="text-xs text-slate-500">**** {card.lastFourDigits}</span>
</div>
</div>
))}
</div>
</div>
{/* Amount */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
<input
type="number"
step="0.01"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full pl-8 pr-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 text-lg font-mono outline-none"
required
autoFocus
/>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Descripción</label>
<input
type="text"
placeholder="Ej: Cena McDonalds, Compra ML"
value={description}
onChange={(e) => setDescription(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-cyan-500/50 focus:border-cyan-500 text-white outline-none"
required
/>
</div>
{/* Date */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
<Calendar size={12} /> Fecha
</label>
<input
type="date"
value={dateStr}
onChange={(e) => setDateStr(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-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
required
/>
</div>
{/* Installments Toggle */}
<div className="flex items-center gap-2 px-4 py-3 bg-slate-800/30 rounded-lg cursor-pointer" onClick={() => setHasInstallments(!hasInstallments)}>
<div className={cn("w-5 h-5 rounded border flex items-center justify-center transition-colors", hasInstallments ? "bg-cyan-500 border-cyan-500" : "border-slate-600 bg-transparent")}>
{hasInstallments && <Layers size={14} className="text-white" />}
</div>
<span className="text-sm text-slate-300 select-none">Es una compra en cuotas</span>
</div>
{/* Installments Inputs */}
{hasInstallments && (
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Cuota N°</label>
<input
type="number"
min="1"
value={installments}
onChange={(e) => setInstallments(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Total Cuotas</label>
<input
type="number"
min="1"
value={totalInstallments}
onChange={(e) => setTotalInstallments(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white"
/>
</div>
</div>
)}
<div className="pt-2">
<button
type="submit"
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
>
Registrar Pago
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,177 @@
'use client'
import { useState } from 'react'
import { useFinanzasStore } from '@/lib/store'
import { X, Calendar, Zap, Droplets, Flame, Wifi } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AddServiceModalProps {
isOpen: boolean
onClose: () => void
}
const SERVICES = [
{ id: 'electricity', label: 'Luz', icon: Zap, color: 'text-yellow-400' },
{ id: 'water', label: 'Agua', icon: Droplets, color: 'text-blue-400' },
{ id: 'gas', label: 'Gas', icon: Flame, color: 'text-orange-400' },
{ id: 'internet', label: 'Internet', icon: Wifi, color: 'text-cyan-400' },
]
export function AddServiceModal({ isOpen, onClose }: AddServiceModalProps) {
const addServiceBill = useFinanzasStore((state) => state.addServiceBill)
const [type, setType] = useState('electricity')
const [amount, setAmount] = useState('')
const [usage, setUsage] = useState('')
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) // YYYY-MM
const [date, setDate] = useState(new Date().toISOString().split('T')[0])
if (!isOpen) return null
const getUnit = (serviceType: string) => {
switch (serviceType) {
case 'electricity': return 'kW'
case 'gas': return 'm³'
case 'water': return 'm³'
default: return ''
}
}
const unit = getUnit(type)
const showUsage = type !== 'internet'
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!amount) return
addServiceBill({
type: type as any,
amount: parseFloat(amount),
usage: usage ? parseFloat(usage) : undefined,
unit: unit || undefined,
date: new Date(date).toISOString(),
period: period,
notes: ''
})
// Reset
setAmount('')
setUsage('')
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h2 className="text-xl font-semibold text-white">Registrar Factura de Servicio</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Service Type Selection */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{SERVICES.map((s) => {
const Icon = s.icon
const isSelected = type === s.id
return (
<div
key={s.id}
onClick={() => setType(s.id)}
className={cn(
"cursor-pointer p-3 rounded-xl border flex flex-col items-center gap-2 transition-all",
isSelected
? "border-cyan-500 bg-cyan-500/10 ring-1 ring-cyan-500"
: "border-slate-800 bg-slate-950 hover:border-slate-700 hover:bg-slate-900"
)}
>
<Icon className={cn("w-6 h-6", s.color)} />
<span className={cn("text-xs font-medium", isSelected ? "text-white" : "text-slate-400")}>{s.label}</span>
</div>
)
})}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Amount */}
<div className={cn("space-y-2", !showUsage && "col-span-2")}>
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
<input
type="number"
step="0.01"
placeholder="0.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full pl-8 pr-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 text-lg font-mono outline-none"
required
autoFocus
/>
</div>
</div>
{/* Usage */}
{showUsage && (
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Consumo ({unit})</label>
<div className="relative">
<input
type="number"
step="0.01"
placeholder="0"
value={usage}
onChange={(e) => setUsage(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 text-lg font-mono outline-none"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm font-medium">{unit}</span>
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Period */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Periodo</label>
<input
type="month"
value={period}
onChange={(e) => setPeriod(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-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
required
/>
</div>
{/* Date */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Fecha Pago</label>
<input
type="date"
value={date}
onChange={(e) => setDate(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-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
required
/>
</div>
</div>
<div className="pt-2">
<button
type="submit"
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
>
Guardar Factura
</button>
</div>
</form>
</div>
</div>
)
}

1
dist/404.html vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
self.__BUILD_MANIFEST={__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/_error":["static/chunks/pages/_error-7ba65e1336b92748.js"],sortedPages:["/_app","/_error"]},self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

View File

@@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
"use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[71],{2489:function(e,t,r){r.d(t,{Z:function(){return n}});let n=(0,r(8755).Z)("x",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]])},4147:function(e,t,r){let n;r.d(t,{Z:function(){return u}});var o={randomUUID:"undefined"!=typeof crypto&&crypto.randomUUID&&crypto.randomUUID.bind(crypto)};let a=new Uint8Array(16),i=[];for(let e=0;e<256;++e)i.push((e+256).toString(16).slice(1));var u=function(e,t,r){return!o.randomUUID||t||e?function(e,t,r){let o=(e=e||{}).random??e.rng?.()??function(){if(!n){if("undefined"==typeof crypto||!crypto.getRandomValues)throw Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");n=crypto.getRandomValues.bind(crypto)}return n(a)}();if(o.length<16)throw Error("Random bytes length must be >= 16");if(o[6]=15&o[6]|64,o[8]=63&o[8]|128,t){if((r=r||0)<0||r+16>t.length)throw RangeError(`UUID byte range ${r}:${r+15} is out of buffer bounds`);for(let e=0;e<16;++e)t[r+e]=o[e];return t}return function(e,t=0){return(i[e[t+0]]+i[e[t+1]]+i[e[t+2]]+i[e[t+3]]+"-"+i[e[t+4]]+i[e[t+5]]+"-"+i[e[t+6]]+i[e[t+7]]+"-"+i[e[t+8]]+i[e[t+9]]+"-"+i[e[t+10]]+i[e[t+11]]+i[e[t+12]]+i[e[t+13]]+i[e[t+14]]+i[e[t+15]]).toLowerCase()}(o)}(e,t,r):o.randomUUID()}},6885:function(e,t,r){r.d(t,{tJ:function(){return o}});let n=e=>t=>{try{let r=e(t);if(r instanceof Promise)return r;return{then:e=>n(e)(r),catch(e){return this}}}catch(e){return{then(e){return this},catch:t=>n(t)(e)}}},o=(e,t)=>(r,o,a)=>{let i,u={storage:function(e,t){let r;try{r=e()}catch(e){return}return{getItem:e=>{var t;let n=e=>null===e?null:JSON.parse(e,void 0),o=null!=(t=r.getItem(e))?t:null;return o instanceof Promise?o.then(n):n(o)},setItem:(e,t)=>r.setItem(e,JSON.stringify(t,void 0)),removeItem:e=>r.removeItem(e)}}(()=>localStorage),partialize:e=>e,version:0,merge:(e,t)=>({...t,...e}),...t},l=!1,s=0,c=new Set,d=new Set,f=u.storage;if(!f)return e((...e)=>{console.warn(`[zustand persist middleware] Unable to update item '${u.name}', the given storage is currently unavailable.`),r(...e)},o,a);let m=()=>{let e=u.partialize({...o()});return f.setItem(u.name,{state:e,version:u.version})},g=a.setState;a.setState=(e,t)=>(g(e,t),m());let h=e((...e)=>(r(...e),m()),o,a);a.getInitialState=()=>h;let p=()=>{var e,t;if(!f)return;let a=++s;l=!1,c.forEach(e=>{var t;return e(null!=(t=o())?t:h)});let g=(null==(t=u.onRehydrateStorage)?void 0:t.call(u,null!=(e=o())?e:h))||void 0;return n(f.getItem.bind(f))(u.name).then(e=>{if(e){if("number"!=typeof e.version||e.version===u.version)return[!1,e.state];if(u.migrate){let t=u.migrate(e.state,e.version);return t instanceof Promise?t.then(e=>[!0,e]):[!0,t]}console.error("State loaded from storage couldn't be migrated since no migrate function was provided")}return[!1,void 0]}).then(e=>{var t;if(a!==s)return;let[n,l]=e;if(r(i=u.merge(l,null!=(t=o())?t:h),!0),n)return m()}).then(()=>{a===s&&(null==g||g(i,void 0),i=o(),l=!0,d.forEach(e=>e(i)))}).catch(e=>{a===s&&(null==g||g(void 0,e))})};return a.persist={setOptions:e=>{u={...u,...e},e.storage&&(f=e.storage)},clearStorage:()=>{null==f||f.removeItem(u.name)},getOptions:()=>u,rehydrate:()=>p(),hasHydrated:()=>l,onHydrate:e=>(c.add(e),()=>{c.delete(e)}),onFinishHydration:e=>(d.add(e),()=>{d.delete(e)})},u.skipHydration||p(),i||h}},3011:function(e,t,r){r.d(t,{U:function(){return l}});var n=r(2265);let o=e=>{let t;let r=new Set,n=(e,n)=>{let o="function"==typeof e?e(t):e;if(!Object.is(o,t)){let e=t;t=(null!=n?n:"object"!=typeof o||null===o)?o:Object.assign({},t,o),r.forEach(r=>r(t,e))}},o=()=>t,a={setState:n,getState:o,getInitialState:()=>i,subscribe:e=>(r.add(e),()=>r.delete(e))},i=t=e(n,o,a);return a},a=e=>e?o(e):o,i=e=>e,u=e=>{let t=a(e),r=e=>(function(e,t=i){let r=n.useSyncExternalStore(e.subscribe,n.useCallback(()=>t(e.getState()),[e,t]),n.useCallback(()=>t(e.getInitialState()),[e,t]));return n.useDebugValue(r),r})(t,e);return Object.assign(r,t),r},l=e=>e?u(e):u}}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[409],{7589:function(e,t,n){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_not-found/page",function(){return n(3634)}])},3634:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return s}}),n(7043);let i=n(7437);n(2265);let o={fontFamily:'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',height:"100vh",textAlign:"center",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center"},l={display:"inline-block"},r={display:"inline-block",margin:"0 20px 0 0",padding:"0 23px 0 0",fontSize:24,fontWeight:500,verticalAlign:"top",lineHeight:"49px"},d={fontSize:14,fontWeight:400,lineHeight:"49px",margin:0};function s(){return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)("title",{children:"404: This page could not be found."}),(0,i.jsx)("div",{style:o,children:(0,i.jsxs)("div",{children:[(0,i.jsx)("style",{dangerouslySetInnerHTML:{__html:"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}),(0,i.jsx)("h1",{className:"next-error-h1",style:r,children:"404"}),(0,i.jsx)("div",{style:l,children:(0,i.jsx)("h2",{style:d,children:"This page could not be found."})})]})})]})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)}},function(e){e.O(0,[971,117,744],function(){return e(e.s=7589)}),_N_E=e.O()}]);

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[78],{4033:function(e,s,n){Promise.resolve().then(n.bind(n,5949))},5949:function(e,s,n){"use strict";n.r(s),n.d(s,{default:function(){return c}});var l=n(7437),t=n(553),i=n(3263),a=n(9294);let r=(0,n(8755).Z)("refresh-cw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);function c(){let{isOpen:e,toggle:s,close:n}=(0,a.A)(),{regenerateAlerts:c,dismissAll:o,unreadCount:u}=(0,i.Z7)();return(0,l.jsxs)("div",{className:"min-h-screen bg-slate-950",children:[(0,l.jsx)(t.YE,{isOpen:e,onClose:n,unreadAlertsCount:u}),(0,l.jsxs)("div",{className:"lg:ml-64 min-h-screen flex flex-col",children:[(0,l.jsx)(t.h4,{onMenuClick:s,title:"Alertas"}),(0,l.jsx)("main",{className:"flex-1 p-4 md:p-6 lg:p-8 pb-20 lg:pb-8",children:(0,l.jsxs)("div",{className:"max-w-4xl mx-auto",children:[(0,l.jsxs)("div",{className:"flex flex-wrap gap-3 mb-6",children:[(0,l.jsxs)("button",{onClick:()=>{c()},className:"inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20",children:[(0,l.jsx)(r,{className:"h-4 w-4"}),"Regenerar Alertas"]}),(0,l.jsx)("button",{onClick:()=>{o()},className:"inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-slate-500/20",children:"Limpiar Todas"})]}),(0,l.jsx)("div",{className:"w-full",children:(0,l.jsx)(i.KG,{})})]})}),(0,l.jsx)(t.zM,{unreadAlertsCount:u})]})]})}}},function(e){e.O(0,[697,71,796,489,971,117,744],function(){return e(e.s=4033)}),_N_E=e.O()}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{1556:function(e,n,t){Promise.resolve().then(t.bind(t,9294)),Promise.resolve().then(t.t.bind(t,8925,23)),Promise.resolve().then(t.t.bind(t,7960,23))},9294:function(e,n,t){"use strict";t.d(n,{A:function(){return u},Providers:function(){return s}});var r=t(7437),o=t(2265);let i=(0,o.createContext)(void 0);function s(e){let{children:n}=e,[t,s]=(0,o.useState)(!0);return(0,r.jsx)(i.Provider,{value:{isOpen:t,toggle:()=>s(e=>!e),close:()=>s(!1),open:()=>s(!0)},children:n})}function u(){let e=(0,o.useContext)(i);if(void 0===e)throw Error("useSidebar must be used within a Providers");return e}},7960:function(){},8925:function(e){e.exports={style:{fontFamily:"'__Inter_f367f3', '__Inter_Fallback_f367f3'",fontStyle:"normal"},className:"__className_f367f3",variable:"__variable_f367f3"}}},function(e){e.O(0,[832,971,117,744],function(){return e(e.s=1556)}),_N_E=e.O()}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{2300:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(2300)}),_N_E=e.O()}]);

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[888],{1597:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_app",function(){return u(8141)}])}},function(n){var _=function(_){return n(n.s=_)};n.O(0,[774,179],function(){return _(1597),_(7253)}),_N_E=n.O()}]);

View File

@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[820],{1981:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_error",function(){return u(8529)}])}},function(n){n.O(0,[888,774,179],function(){return n(n.s=1981)}),_N_E=n.O()}]);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(){"use strict";var e,t,n,r,o,u,i,c,f,a={},l={};function s(e){var t=l[e];if(void 0!==t)return t.exports;var n=l[e]={exports:{}},r=!0;try{a[e].call(n.exports,n,n.exports,s),r=!1}finally{r&&delete l[e]}return n.exports}s.m=a,e=[],s.O=function(t,n,r,o){if(n){o=o||0;for(var u=e.length;u>0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[n,r,o];return}for(var i=1/0,u=0;u<e.length;u++){for(var n=e[u][0],r=e[u][1],o=e[u][2],c=!0,f=0;f<n.length;f++)i>=o&&Object.keys(s.O).every(function(e){return s.O[e](n[f])})?n.splice(f--,1):(c=!1,o<i&&(i=o));if(c){e.splice(u--,1);var a=r();void 0!==a&&(t=a)}}return t},s.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return s.d(t,{a:t}),t},n=Object.getPrototypeOf?function(e){return Object.getPrototypeOf(e)}:function(e){return e.__proto__},s.t=function(e,r){if(1&r&&(e=this(e)),8&r||"object"==typeof e&&e&&(4&r&&e.__esModule||16&r&&"function"==typeof e.then))return e;var o=Object.create(null);s.r(o);var u={};t=t||[null,n({}),n([]),n(n)];for(var i=2&r&&e;"object"==typeof i&&!~t.indexOf(i);i=n(i))Object.getOwnPropertyNames(i).forEach(function(t){u[t]=function(){return e[t]}});return u.default=function(){return e},s.d(o,u),o},s.d=function(e,t){for(var n in t)s.o(t,n)&&!s.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},s.f={},s.e=function(e){return Promise.all(Object.keys(s.f).reduce(function(t,n){return s.f[n](e,t),t},[]))},s.u=function(e){},s.miniCssF=function(e){},s.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),s.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r={},o="_N_E:",s.l=function(e,t,n,u){if(r[e]){r[e].push(t);return}if(void 0!==n)for(var i,c,f=document.getElementsByTagName("script"),a=0;a<f.length;a++){var l=f[a];if(l.getAttribute("src")==e||l.getAttribute("data-webpack")==o+n){i=l;break}}i||(c=!0,(i=document.createElement("script")).charset="utf-8",i.timeout=120,s.nc&&i.setAttribute("nonce",s.nc),i.setAttribute("data-webpack",o+n),i.src=s.tu(e)),r[e]=[t];var d=function(t,n){i.onerror=i.onload=null,clearTimeout(p);var o=r[e];if(delete r[e],i.parentNode&&i.parentNode.removeChild(i),o&&o.forEach(function(e){return e(n)}),t)return t(n)},p=setTimeout(d.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=d.bind(null,i.onerror),i.onload=d.bind(null,i.onload),c&&document.head.appendChild(i)},s.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.tt=function(){return void 0===u&&(u={createScriptURL:function(e){return e}},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(u=trustedTypes.createPolicy("nextjs#bundler",u))),u},s.tu=function(e){return s.tt().createScriptURL(e)},s.p="/_next/",i={272:0,832:0},s.f.j=function(e,t){var n=s.o(i,e)?i[e]:void 0;if(0!==n){if(n)t.push(n[2]);else if(/^(27|83)2$/.test(e))i[e]=0;else{var r=new Promise(function(t,r){n=i[e]=[t,r]});t.push(n[2]=r);var o=s.p+s.u(e),u=Error();s.l(o,function(t){if(s.o(i,e)&&(0!==(n=i[e])&&(i[e]=void 0),n)){var r=t&&("load"===t.type?"missing":t.type),o=t&&t.target&&t.target.src;u.message="Loading chunk "+e+" failed.\n("+r+": "+o+")",u.name="ChunkLoadError",u.type=r,u.request=o,n[1](u)}},"chunk-"+e,e)}}},s.O.j=function(e){return 0===i[e]},c=function(e,t){var n,r,o=t[0],u=t[1],c=t[2],f=0;if(o.some(function(e){return 0!==i[e]})){for(n in u)s.o(u,n)&&(s.m[n]=u[n]);if(c)var a=c(s)}for(e&&e(t);f<o.length;f++)r=o[f],s.o(i,r)&&i[r]&&i[r][0](),i[r]=0;return s.O(a)},(f=self.webpackChunk_N_E=self.webpackChunk_N_E||[]).forEach(c.bind(null,0)),f.push=c.bind(null,f.push.bind(f))}();

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More