Initial commit - cleaned for CV
This commit is contained in:
+39
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## ✨ 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!
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { destroySession } from '@/lib/auth';
|
||||
|
||||
export async function POST() {
|
||||
destroySession();
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { BudgetForm } from './BudgetForm';
|
||||
export { BudgetRing } from './BudgetRing';
|
||||
export { BudgetProgress } from './BudgetProgress';
|
||||
export { BudgetCard } from './BudgetCard';
|
||||
export { BudgetSection } from './BudgetSection';
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { CreditCardWidget } from './CreditCardWidget'
|
||||
export { CreditCardForm } from './CreditCardForm'
|
||||
export { CardPaymentForm } from './CardPaymentForm'
|
||||
export { MiniCard } from './MiniCard'
|
||||
export { CardSection } from './CardSection'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DebtCard } from './DebtCard';
|
||||
export { DebtSection } from './DebtSection';
|
||||
export { FixedDebtForm } from './FixedDebtForm';
|
||||
export { VariableDebtForm } from './VariableDebtForm';
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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()}]);
|
||||
@@ -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
@@ -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
@@ -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()}]);
|
||||
@@ -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()}]);
|
||||
@@ -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
@@ -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))}();
|
||||
+3
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
Reference in New Issue
Block a user