Compare commits
5 Commits
9233e3fc3b
...
f7b5d3777d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b5d3777d | ||
|
|
759f0a636b | ||
|
|
e5c9de2df5 | ||
|
|
020218275f | ||
|
|
811c78ffa5 |
73
.gitignore
vendored
73
.gitignore
vendored
@@ -1,34 +1,39 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
# Next.js
|
# Next.js
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
build
|
build
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# Vercel
|
# Vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
server-settings.json
|
server-settings.json
|
||||||
|
.env
|
||||||
|
auth-otp.json
|
||||||
|
|
||||||
|
# Local dev
|
||||||
|
local_Caddyfile
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
# 🚀 Deployment Guideline: Finanzas Personales (Next.js + Telegram Bot)
|
|
||||||
|
|
||||||
This document contains step-by-step instructions to deploy the "Finanzas Personales" application to a Linux VPS (Ubuntu/Debian recommended).
|
|
||||||
|
|
||||||
## 1. Prerequisites
|
|
||||||
- **VPS**: A server with Ubuntu 22.04 or later.
|
|
||||||
- **Domain**: A domain pointing to your VPS IP (e.g., `finanzas.tusitio.com`).
|
|
||||||
- **Gitea Access**: Ensure the VPS can pull from your private Gitea repository.
|
|
||||||
|
|
||||||
## 2. Server Setup (Run as root/sudo)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update system
|
|
||||||
apt update && apt upgrade -y
|
|
||||||
|
|
||||||
# Install Node.js 18+ (using NVM is recommended, but apt works for deployment)
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
|
||||||
apt install -y nodejs nginx git
|
|
||||||
|
|
||||||
# Install Process Manager (PM2)
|
|
||||||
npm install -g pm2
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Clone Repository & Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Navigate to web directory
|
|
||||||
cd /var/www
|
|
||||||
|
|
||||||
# Clone your repo (Use your Gitea token if private)
|
|
||||||
git clone https://<YOUR_GITEA_URL>/<USER>/finanzas.git
|
|
||||||
cd finanzas
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build the Next.js app
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Environment Configuration
|
|
||||||
Since we use a custom `server-settings.json`, you need to configure it manually on the server once.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create the settings file manually (or copy from local if you have sw access)
|
|
||||||
nano server-settings.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Paste your JSON configuration (get this from your local `server-settings.json`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"telegram": {
|
|
||||||
"botToken": "YOUR_BOT_TOKEN",
|
|
||||||
"chatId": "YOUR_CHAT_ID"
|
|
||||||
},
|
|
||||||
"aiProviders": [
|
|
||||||
{
|
|
||||||
"id": "uuid...",
|
|
||||||
"name": "Service Name",
|
|
||||||
"endpoint": "https://api...",
|
|
||||||
"token": "sk-...",
|
|
||||||
"model": "gpt-4o"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Startup with PM2 (Keep Apps Alive)
|
|
||||||
|
|
||||||
We need to run two processes: The Next.js Web App and the Telegram Bot.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Start Next.js (Port 3000)
|
|
||||||
pm2 start npm --name "finanzas-web" -- start -- -p 3000
|
|
||||||
|
|
||||||
# 2. Start Telegram Bot
|
|
||||||
pm2 start npm --name "finanzas-bot" -- run bot
|
|
||||||
|
|
||||||
# Save list so they revive on reboot
|
|
||||||
pm2 save
|
|
||||||
pm2 startup
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Nginx Reverse Proxy (Expose to Internet)
|
|
||||||
|
|
||||||
Configure Nginx to safely expose port 3000 to the web.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nano /etc/nginx/sites-available/finanzas
|
|
||||||
```
|
|
||||||
|
|
||||||
Add this content:
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name finanzas.tusitio.com; # REPLACE THIS
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable site and restart Nginx:
|
|
||||||
```bash
|
|
||||||
ln -s /etc/nginx/sites-available/finanzas /etc/nginx/sites-enabled/
|
|
||||||
nginx -t
|
|
||||||
systemctl restart nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. SSL Certificate (HTTPS)
|
|
||||||
Use Certbot to secure your site for free.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt install -y certbot python3-certbot-nginx
|
|
||||||
certbot --nginx -d finanzas.tusitio.com
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Updates
|
|
||||||
When you push new code to Gitea, update the server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/finanzas
|
|
||||||
git pull
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
pm2 restart finanzas-web
|
|
||||||
pm2 restart finanzas-bot
|
|
||||||
```
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
import { DashboardLayout } from '@/components/layout/DashboardLayout'
|
||||||
import { AlertPanel, useAlerts } from '@/components/alerts'
|
import { AlertPanel, useAlerts } from '@/components/alerts'
|
||||||
import { useSidebar } from '@/app/providers'
|
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
export default function AlertsPage() {
|
export default function AlertsPage() {
|
||||||
const { isOpen, toggle, close } = useSidebar()
|
const { regenerateAlerts, dismissAll } = useAlerts()
|
||||||
const { regenerateAlerts, dismissAll, unreadCount } = useAlerts()
|
|
||||||
|
|
||||||
const handleRegenerateAlerts = () => {
|
const handleRegenerateAlerts = () => {
|
||||||
regenerateAlerts()
|
regenerateAlerts()
|
||||||
@@ -18,41 +16,31 @@ export default function AlertsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950">
|
<DashboardLayout title="Alertas">
|
||||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
<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>
|
||||||
|
|
||||||
<div className="lg:ml-64 min-h-screen flex flex-col">
|
<button
|
||||||
<Header onMenuClick={toggle} title="Alertas" />
|
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>
|
||||||
|
|
||||||
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20 lg:pb-8">
|
{/* Alert Panel */}
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="w-full">
|
||||||
{/* Action Buttons */}
|
<AlertPanel />
|
||||||
<div className="flex flex-wrap gap-3 mb-6">
|
</div>
|
||||||
<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>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<MobileNav unreadAlertsCount={unreadCount} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
54
app/api/auth/login/route.ts
Normal file
54
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { findUser, verifyPassword, createSession } from '@/lib/auth';
|
||||||
|
import { generateOTP } from '@/lib/otp';
|
||||||
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
|
||||||
|
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
|
||||||
|
async function sendTelegramOTP(chatId: string, otp: string) {
|
||||||
|
if (!BOT_TOKEN) return false;
|
||||||
|
try {
|
||||||
|
const bot = new TelegramBot(BOT_TOKEN, { polling: false });
|
||||||
|
await bot.sendMessage(chatId, `🔐 *Código de Acceso Finanzas*\n\nTu código es: \`${otp}\`\n\nSi no intentaste ingresar, ignora este mensaje.`, { parse_mode: 'Markdown' });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Telegram send error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await req.json();
|
||||||
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown';
|
||||||
|
|
||||||
|
const user = findUser(username);
|
||||||
|
|
||||||
|
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||||
|
return NextResponse.json({ error: 'Credenciales inválidas' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP
|
||||||
|
const isKnownIp = user.knownIps.includes(ip);
|
||||||
|
|
||||||
|
if (isKnownIp) {
|
||||||
|
// Login success directly
|
||||||
|
await createSession(user);
|
||||||
|
return NextResponse.json({ success: true, requireOtp: false });
|
||||||
|
} else {
|
||||||
|
// Require OTP
|
||||||
|
const otp = generateOTP(user.username);
|
||||||
|
|
||||||
|
const sent = await sendTelegramOTP(user.chatId, otp);
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
return NextResponse.json({ error: 'No se pudo enviar el código OTP a Telegram' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, requireOtp: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json({ error: 'Error interno' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/api/auth/logout/route.ts
Normal file
7
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { destroySession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
destroySession();
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
41
app/api/auth/register/route.ts
Normal file
41
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { saveUser, findUser, hashPassword, createSession } from '@/lib/auth';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { username, password, chatId } = await req.json();
|
||||||
|
|
||||||
|
if (!username || !password || !chatId) {
|
||||||
|
return NextResponse.json({ error: 'Faltan datos requeridos' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findUser(username)) {
|
||||||
|
return NextResponse.json({ error: 'El usuario ya existe' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
// Get IP
|
||||||
|
const ip = req.headers.get('x-forwarded-for') || '127.0.0.1';
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: randomUUID(),
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
chatId,
|
||||||
|
knownIps: [ip] // Register current IP as known initially
|
||||||
|
};
|
||||||
|
|
||||||
|
saveUser(newUser);
|
||||||
|
|
||||||
|
// Auto login after register
|
||||||
|
await createSession(newUser);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Register error:', error);
|
||||||
|
return NextResponse.json({ error: 'Error interno del servidor' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/api/auth/verify-otp/route.ts
Normal file
34
app/api/auth/verify-otp/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { findUser, saveUser, createSession } from '@/lib/auth';
|
||||||
|
import { verifyOTP } from '@/lib/otp';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { username, otp } = await req.json();
|
||||||
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown';
|
||||||
|
|
||||||
|
if (!verifyOTP(username, otp)) {
|
||||||
|
return NextResponse.json({ error: 'Código inválido o expirado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = findUser(username);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add IP to known list if not exists
|
||||||
|
if (!user.knownIps.includes(ip) && ip !== 'unknown') {
|
||||||
|
user.knownIps.push(ip);
|
||||||
|
saveUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login success
|
||||||
|
await createSession(user);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OTP Verify error:', error);
|
||||||
|
return NextResponse.json({ error: 'Error interno' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/api/sync/route.ts
Normal file
28
app/api/sync/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDatabase, saveDatabase } from '@/lib/server-db';
|
||||||
|
import { verifySession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = getDatabase(session.username);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
saveDatabase(session.username, body);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Invalid Data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
import { DashboardLayout } from '@/components/layout/DashboardLayout'
|
||||||
import { BudgetSection } from '@/components/budget'
|
import { BudgetSection } from '@/components/budget'
|
||||||
import { useSidebar } from '@/app/providers'
|
|
||||||
import { useAlerts } from '@/components/alerts'
|
|
||||||
|
|
||||||
export default function BudgetPage() {
|
export default function BudgetPage() {
|
||||||
const { isOpen, close, toggle } = useSidebar()
|
|
||||||
const { unreadCount } = useAlerts()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-slate-950">
|
<DashboardLayout title="Presupuesto">
|
||||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
<BudgetSection />
|
||||||
|
</DashboardLayout>
|
||||||
<div className="flex-1 flex flex-col min-h-screen">
|
|
||||||
<Header onMenuClick={toggle} title="Presupuesto" />
|
|
||||||
|
|
||||||
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20">
|
|
||||||
<BudgetSection />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<MobileNav unreadAlertsCount={unreadCount} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Sidebar, Header, MobileNav } from '@/components/layout';
|
import { DashboardLayout } from '@/components/layout/DashboardLayout';
|
||||||
import { CardSection } from '@/components/cards';
|
import { CardSection } from '@/components/cards';
|
||||||
import { useSidebar } from '@/app/providers';
|
|
||||||
import { useAlerts } from '@/components/alerts';
|
|
||||||
|
|
||||||
export default function CardsPage() {
|
export default function CardsPage() {
|
||||||
const { isOpen, toggle, close } = useSidebar();
|
|
||||||
const { unreadCount } = useAlerts();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950">
|
<DashboardLayout title="Tarjetas de Crédito">
|
||||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
<CardSection />
|
||||||
|
</DashboardLayout>
|
||||||
<div className="lg:ml-64 min-h-screen flex flex-col">
|
|
||||||
<Header onMenuClick={toggle} title="Tarjetas de Crédito" />
|
|
||||||
|
|
||||||
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20">
|
|
||||||
<CardSection />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MobileNav unreadAlertsCount={unreadCount} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
import { DashboardLayout } from '@/components/layout/DashboardLayout'
|
||||||
import { DebtSection } from '@/components/debts'
|
import { DebtSection } from '@/components/debts'
|
||||||
import { useSidebar } from '@/app/providers'
|
|
||||||
import { useAlerts } from '@/components/alerts'
|
|
||||||
|
|
||||||
export default function DebtsPage() {
|
export default function DebtsPage() {
|
||||||
const { isOpen, close, open } = useSidebar()
|
|
||||||
const { unreadCount } = useAlerts()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950">
|
<DashboardLayout title="Deudas">
|
||||||
{/* Sidebar */}
|
<DebtSection />
|
||||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
</DashboardLayout>
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="lg:ml-64 min-h-screen flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<Header onMenuClick={open} title="Deudas" />
|
|
||||||
|
|
||||||
{/* Page content */}
|
|
||||||
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20 lg:pb-8">
|
|
||||||
<DebtSection />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
|
||||||
<MobileNav unreadAlertsCount={unreadCount} />
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
165
app/incomes/page.tsx
Normal file
165
app/incomes/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useFinanzasStore } from '@/lib/store'
|
||||||
|
import { Plus, Trash2, TrendingUp, DollarSign } from 'lucide-react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { es } from 'date-fns/locale'
|
||||||
|
import { DashboardLayout } from '@/components/layout/DashboardLayout'
|
||||||
|
|
||||||
|
export default function IncomesPage() {
|
||||||
|
const incomes = useFinanzasStore((state) => state.incomes) || []
|
||||||
|
const addIncome = useFinanzasStore((state) => state.addIncome)
|
||||||
|
const removeIncome = useFinanzasStore((state) => state.removeIncome)
|
||||||
|
|
||||||
|
const [newIncome, setNewIncome] = useState({
|
||||||
|
amount: '',
|
||||||
|
description: '',
|
||||||
|
category: 'salary' as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!newIncome.amount || !newIncome.description) return
|
||||||
|
|
||||||
|
addIncome({
|
||||||
|
amount: parseFloat(newIncome.amount),
|
||||||
|
description: newIncome.description,
|
||||||
|
category: newIncome.category,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
setNewIncome({ amount: '', description: '', category: 'salary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalIncomes = incomes.reduce((acc, curr) => acc + curr.amount, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout title="Ingresos">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold flex items-center gap-2 text-white">
|
||||||
|
<TrendingUp className="text-green-500" /> Ingresos
|
||||||
|
</h2>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Total Acumulado</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">
|
||||||
|
${totalIncomes.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{/* Formulario */}
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl md:col-span-1 h-fit overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-700">
|
||||||
|
<h3 className="font-semibold text-lg text-white">Nuevo Ingreso</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-400 block mb-2">Descripción</label>
|
||||||
|
<input
|
||||||
|
placeholder="Ej. Pago Freelance"
|
||||||
|
value={newIncome.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewIncome({ ...newIncome, description: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full bg-slate-700 border-slate-600 border rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-400 block mb-2">Monto</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={newIncome.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewIncome({ ...newIncome, amount: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full pl-9 bg-slate-700 border-slate-600 border rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-400 block mb-2">Categoría</label>
|
||||||
|
<select
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
value={newIncome.category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewIncome({
|
||||||
|
...newIncome,
|
||||||
|
category: e.target.value as any,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="salary">Salario</option>
|
||||||
|
<option value="freelance">Freelance</option>
|
||||||
|
<option value="business">Negocio</option>
|
||||||
|
<option value="gift">Regalo</option>
|
||||||
|
<option value="other">Otro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-2 px-4 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Agregar Ingreso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista */}
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl md:col-span-2 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-700">
|
||||||
|
<h3 className="font-semibold text-lg text-white">Historial de Ingresos</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{incomes.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-8">
|
||||||
|
No hay ingresos registrados aún.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{incomes
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
)
|
||||||
|
.map((income) => (
|
||||||
|
<div
|
||||||
|
key={income.id}
|
||||||
|
className="flex items-center justify-between p-4 bg-slate-700/50 rounded-lg border border-slate-700 hover:border-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-white">
|
||||||
|
{income.description}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 capitalize">
|
||||||
|
{income.category} •{' '}
|
||||||
|
{format(new Date(income.date), "d 'de' MMMM", {
|
||||||
|
locale: es,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-emerald-400 font-mono font-bold">
|
||||||
|
+${income.amount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeIncome(income.id)}
|
||||||
|
className="text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
app/login/page.tsx
Normal file
193
app/login/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Lock, User, Key, ArrowRight, ShieldCheck, Loader2 } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [step, setStep] = useState<'credentials' | 'otp'>('credentials');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
otp: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: formData.username, password: formData.password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al iniciar sesión');
|
||||||
|
|
||||||
|
if (data.requireOtp) {
|
||||||
|
setStep('otp');
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/verify-otp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: formData.username, otp: formData.otp })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Código incorrecto');
|
||||||
|
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-slate-950/50 p-6 text-center border-b border-slate-800">
|
||||||
|
<div className="mx-auto w-12 h-12 bg-emerald-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<Lock className="w-6 h-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-1">Bienvenido</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Sistema de Finanzas Personales</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'credentials' ? (
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Usuario</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
onChange={e => setFormData({...formData, username: e.target.value})}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all placeholder:text-slate-600"
|
||||||
|
placeholder="Ingresa tu usuario"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Contraseña</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Key className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={e => setFormData({...formData, password: e.target.value})}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all placeholder:text-slate-600"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin w-5 h-5" /> : (
|
||||||
|
<>
|
||||||
|
Ingresar <ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleVerifyOtp} className="space-y-4 animate-in slide-in-from-right-8 fade-in duration-300">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-500/10 rounded-full mb-4">
|
||||||
|
<ShieldCheck className="w-8 h-8 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white">Verificación de Identidad</h3>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
Hemos enviado un código a tu Telegram.
|
||||||
|
<br />Ingrésalo para continuar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
maxLength={6}
|
||||||
|
value={formData.otp}
|
||||||
|
onChange={e => setFormData({...formData, otp: e.target.value.replace(/\D/g, '')})}
|
||||||
|
className="w-full text-center text-2xl tracking-[0.5em] font-mono py-3 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
|
||||||
|
placeholder="000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || formData.otp.length !== 6}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin w-5 h-5" /> : 'Verificar Código'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep('credentials')}
|
||||||
|
className="w-full text-slate-500 text-sm hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
Volver atrás
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950/50 p-4 text-center border-t border-slate-800">
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
¿No tienes cuenta?{' '}
|
||||||
|
<Link href="/register" className="text-emerald-500 hover:text-emerald-400 font-medium hover:underline">
|
||||||
|
Regístrate aquí
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
app/page.tsx
100
app/page.tsx
@@ -1,25 +1,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
|
||||||
import { SummarySection, QuickActions, RecentActivity } from '@/components/dashboard'
|
import { SummarySection, QuickActions, RecentActivity } from '@/components/dashboard'
|
||||||
import { useSidebar } from '@/app/providers'
|
|
||||||
import { useFinanzasStore } from '@/lib/store'
|
import { useFinanzasStore } from '@/lib/store'
|
||||||
import { AlertBanner, useAlerts } from '@/components/alerts'
|
import { AlertBanner, useAlerts } from '@/components/alerts'
|
||||||
import { AddDebtModal } from '@/components/modals/AddDebtModal'
|
import { AddDebtModal } from '@/components/modals/AddDebtModal'
|
||||||
import { AddCardModal } from '@/components/modals/AddCardModal'
|
import { AddCardModal } from '@/components/modals/AddCardModal'
|
||||||
import { AddPaymentModal } from '@/components/modals/AddPaymentModal'
|
import { AddPaymentModal } from '@/components/modals/AddPaymentModal'
|
||||||
|
import { DashboardLayout } from '@/components/layout/DashboardLayout'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// Sidebar control
|
|
||||||
const sidebar = useSidebar()
|
|
||||||
|
|
||||||
// Datos del store
|
// Datos del store
|
||||||
const markAlertAsRead = useFinanzasStore((state) => state.markAlertAsRead)
|
const markAlertAsRead = useFinanzasStore((state) => state.markAlertAsRead)
|
||||||
const deleteAlert = useFinanzasStore((state) => state.deleteAlert)
|
const deleteAlert = useFinanzasStore((state) => state.deleteAlert)
|
||||||
|
|
||||||
// Alertas
|
// Alertas
|
||||||
const { unreadAlerts, unreadCount, regenerateAlerts } = useAlerts()
|
const { unreadAlerts, regenerateAlerts } = useAlerts()
|
||||||
|
|
||||||
// Estados locales para modales
|
// Estados locales para modales
|
||||||
const [isAddDebtModalOpen, setIsAddDebtModalOpen] = useState(false)
|
const [isAddDebtModalOpen, setIsAddDebtModalOpen] = useState(false)
|
||||||
@@ -31,23 +27,6 @@ export default function Home() {
|
|||||||
regenerateAlerts()
|
regenerateAlerts()
|
||||||
}, [regenerateAlerts])
|
}, [regenerateAlerts])
|
||||||
|
|
||||||
// Efecto para manejar resize de ventana
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (window.innerWidth >= 1024) {
|
|
||||||
sidebar.open()
|
|
||||||
} else {
|
|
||||||
sidebar.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estado inicial
|
|
||||||
handleResize()
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [sidebar])
|
|
||||||
|
|
||||||
// Handlers para modales
|
// Handlers para modales
|
||||||
const handleAddDebt = () => {
|
const handleAddDebt = () => {
|
||||||
setIsAddDebtModalOpen(true)
|
setIsAddDebtModalOpen(true)
|
||||||
@@ -65,54 +44,35 @@ export default function Home() {
|
|||||||
const topAlerts = unreadAlerts.slice(0, 3)
|
const topAlerts = unreadAlerts.slice(0, 3)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-slate-950">
|
<DashboardLayout title="Dashboard">
|
||||||
{/* Sidebar */}
|
<div className="space-y-6">
|
||||||
<Sidebar
|
{/* Alertas destacadas */}
|
||||||
isOpen={sidebar.isOpen}
|
{topAlerts.length > 0 && (
|
||||||
onClose={sidebar.close}
|
<div className="space-y-3">
|
||||||
unreadAlertsCount={unreadCount}
|
{topAlerts.map((alert) => (
|
||||||
/>
|
<AlertBanner
|
||||||
|
key={alert.id}
|
||||||
{/* Main content */}
|
alert={alert}
|
||||||
<div className="flex flex-1 flex-col lg:ml-0">
|
onDismiss={() => deleteAlert(alert.id)}
|
||||||
{/* Header */}
|
onMarkRead={() => markAlertAsRead(alert.id)}
|
||||||
<Header onMenuClick={sidebar.toggle} title="Dashboard" />
|
/>
|
||||||
|
))}
|
||||||
{/* Main content area */}
|
|
||||||
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20 lg:pb-8">
|
|
||||||
<div className="mx-auto max-w-7xl 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>
|
</div>
|
||||||
</main>
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile navigation */}
|
{/* Sección de resumen */}
|
||||||
<MobileNav unreadAlertsCount={unreadCount} />
|
<SummarySection />
|
||||||
|
|
||||||
|
{/* Acciones rápidas */}
|
||||||
|
<QuickActions
|
||||||
|
onAddDebt={handleAddDebt}
|
||||||
|
onAddCard={handleAddCard}
|
||||||
|
onAddPayment={handleAddPayment}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actividad reciente */}
|
||||||
|
<RecentActivity limit={5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Modales */}
|
{/* Modales */}
|
||||||
<AddDebtModal
|
<AddDebtModal
|
||||||
@@ -129,6 +89,6 @@ export default function Home() {
|
|||||||
isOpen={isAddPaymentModalOpen}
|
isOpen={isAddPaymentModalOpen}
|
||||||
onClose={() => setIsAddPaymentModalOpen(false)}
|
onClose={() => setIsAddPaymentModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</DashboardLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, ReactNode } from "react";
|
import { createContext, useContext, useState, ReactNode } from "react";
|
||||||
|
import { DataSync } from "@/components/DataSync";
|
||||||
|
|
||||||
interface SidebarContextType {
|
interface SidebarContextType {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -27,6 +28,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
open: openSidebar,
|
open: openSidebar,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<DataSync />
|
||||||
{children}
|
{children}
|
||||||
</SidebarContext.Provider>
|
</SidebarContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
135
app/register/page.tsx
Normal file
135
app/register/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { UserPlus, User, Key, MessageSquare, Loader2 } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
chatId: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error al registrarse');
|
||||||
|
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-slate-950/50 p-6 text-center border-b border-slate-800">
|
||||||
|
<div className="mx-auto w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<UserPlus className="w-6 h-6 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-1">Crear Cuenta</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Configura tu acceso a Finanzas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleRegister} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Usuario</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
onChange={e => setFormData({...formData, username: e.target.value})}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all placeholder:text-slate-600"
|
||||||
|
placeholder="Elige un usuario"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Contraseña</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Key className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={e => setFormData({...formData, password: e.target.value})}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all placeholder:text-slate-600"
|
||||||
|
placeholder="Mínimo 6 caracteres"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Telegram Chat ID</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare className="absolute left-3 top-2.5 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.chatId}
|
||||||
|
onChange={e => setFormData({...formData, chatId: e.target.value})}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all placeholder:text-slate-600"
|
||||||
|
placeholder="Ej: 123456789"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-500 flex gap-1">
|
||||||
|
<span>ℹ️</span>
|
||||||
|
Envía /start a tu bot para obtener este ID.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin w-5 h-5" /> : 'Registrar Cuenta'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950/50 p-4 text-center border-t border-slate-800">
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
¿Ya tienes cuenta?{' '}
|
||||||
|
<Link href="/login" className="text-blue-500 hover:text-blue-400 font-medium hover:underline">
|
||||||
|
Inicia Sesión
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,142 +1,145 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useFinanzasStore } from '@/lib/store'
|
import { useFinanzasStore } from '@/lib/store'
|
||||||
import { predictNextBill, calculateTrend } from '@/lib/predictions'
|
import { predictNextBill, calculateTrend } from '@/lib/predictions'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency } from '@/lib/utils'
|
||||||
import { Zap, Droplets, Flame, Wifi, TrendingUp, TrendingDown, Plus, History } from 'lucide-react'
|
import { Zap, Droplets, Flame, Wifi, TrendingUp, TrendingDown, Plus, History } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { AddServiceModal } from '@/components/modals/AddServiceModal'
|
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' },
|
const SERVICES = [
|
||||||
{ id: 'water', label: 'Agua', icon: Droplets, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
{ id: 'electricity', label: 'Luz (Electricidad)', icon: Zap, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||||
{ id: 'gas', label: 'Gas', icon: Flame, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
{ id: 'water', label: 'Agua', icon: Droplets, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||||
{ id: 'internet', label: 'Internet', icon: Wifi, color: 'text-cyan-400', bg: 'bg-cyan-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)
|
export default function ServicesPage() {
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
|
const serviceBills = useFinanzasStore((state) => state.serviceBills)
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
return (
|
||||||
|
<DashboardLayout title="Servicios">
|
||||||
{/* Header */}
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
||||||
<div>
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-white">Servicios y Predicciones</h1>
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<p className="text-slate-400 text-sm">Gestiona tus consumos de Luz, Agua y Gas.</p>
|
<div>
|
||||||
</div>
|
<h2 className="text-2xl font-bold text-white">Servicios y Predicciones</h2>
|
||||||
<button
|
<p className="text-slate-400 text-sm">Gestiona tus consumos de Luz, Agua y Gas.</p>
|
||||||
onClick={() => setIsAddModalOpen(true)}
|
</div>
|
||||||
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"
|
<button
|
||||||
>
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
<Plus size={18} /> Nuevo Pago
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
<Plus size={18} /> Nuevo Pago
|
||||||
|
</button>
|
||||||
{/* Service Cards */}
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{SERVICES.map((service) => {
|
{/* Service Cards */}
|
||||||
const Icon = service.icon
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
const prediction = predictNextBill(serviceBills, service.id as any)
|
{SERVICES.map((service) => {
|
||||||
const trend = calculateTrend(serviceBills, service.id as any)
|
const Icon = service.icon
|
||||||
const lastBill = serviceBills
|
const prediction = predictNextBill(serviceBills, service.id as any)
|
||||||
.filter(b => b.type === service.id)
|
const trend = calculateTrend(serviceBills, service.id as any)
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0]
|
const lastBill = serviceBills
|
||||||
|
.filter(b => b.type === service.id)
|
||||||
return (
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0]
|
||||||
<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">
|
return (
|
||||||
<div className={cn("p-2 rounded-lg", service.bg)}>
|
<div key={service.id} className="bg-slate-900 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||||
<Icon className={cn("w-6 h-6", service.color)} />
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div className={cn("p-2 rounded-lg", service.bg)}>
|
||||||
{trend !== 0 && (
|
<Icon className={cn("w-6 h-6", service.color)} />
|
||||||
<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")}>
|
</div>
|
||||||
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
{trend !== 0 && (
|
||||||
{Math.abs(trend).toFixed(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")}>
|
||||||
</div>
|
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||||
)}
|
{Math.abs(trend).toFixed(0)}%
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
</div>
|
||||||
<p className="text-slate-400 text-sm font-medium">{service.label}</p>
|
|
||||||
<div className="flex items-baseline gap-2">
|
<div>
|
||||||
<h3 className="text-2xl font-bold text-white mt-1">
|
<p className="text-slate-400 text-sm font-medium">{service.label}</p>
|
||||||
{formatCurrency(prediction || (lastBill?.amount ?? 0))}
|
<div className="flex items-baseline gap-2">
|
||||||
</h3>
|
<h3 className="text-2xl font-bold text-white mt-1">
|
||||||
{prediction > 0 && <span className="text-xs text-slate-500 font-mono">(est.)</span>}
|
{formatCurrency(prediction || (lastBill?.amount ?? 0))}
|
||||||
</div>
|
</h3>
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
{prediction > 0 && <span className="text-xs text-slate-500 font-mono">(est.)</span>}
|
||||||
{lastBill
|
</div>
|
||||||
? `Último: ${formatCurrency(lastBill.amount)}`
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
: 'Sin historial'}
|
{lastBill
|
||||||
</p>
|
? `Último: ${formatCurrency(lastBill.amount)}`
|
||||||
</div>
|
: 'Sin historial'}
|
||||||
</div>
|
</p>
|
||||||
)
|
</div>
|
||||||
})}
|
</div>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
{/* History List */}
|
</div>
|
||||||
<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 List */}
|
||||||
<History size={18} className="text-slate-400" />
|
<div className="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||||
<h3 className="text-lg font-semibold text-white">Historial de Pagos</h3>
|
<div className="p-5 border-b border-slate-800 flex items-center gap-2">
|
||||||
</div>
|
<History size={18} className="text-slate-400" />
|
||||||
<div className="divide-y divide-slate-800">
|
<h3 className="text-lg font-semibold text-white">Historial de Pagos</h3>
|
||||||
{serviceBills.length === 0 ? (
|
</div>
|
||||||
<div className="p-8 text-center text-slate-500 text-sm">
|
<div className="divide-y divide-slate-800">
|
||||||
No hay facturas registradas. Comienza agregando una para ver predicciones.
|
{serviceBills.length === 0 ? (
|
||||||
</div>
|
<div className="p-8 text-center text-slate-500 text-sm">
|
||||||
) : (
|
No hay facturas registradas. Comienza agregando una para ver predicciones.
|
||||||
serviceBills
|
</div>
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
) : (
|
||||||
.map((bill) => {
|
serviceBills
|
||||||
const service = SERVICES.find(s => s.id === bill.type)
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
const Icon = service?.icon || Zap
|
.map((bill) => {
|
||||||
|
const service = SERVICES.find(s => s.id === bill.type)
|
||||||
return (
|
const Icon = service?.icon || Zap
|
||||||
<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">
|
return (
|
||||||
<div className={cn("p-2 rounded-lg", service?.bg || 'bg-slate-800')}>
|
<div key={bill.id} className="p-4 flex items-center justify-between hover:bg-slate-800/50 transition-colors">
|
||||||
<Icon className={cn("w-5 h-5", service?.color || 'text-slate-400')} />
|
<div className="flex items-center gap-4">
|
||||||
</div>
|
<div className={cn("p-2 rounded-lg", service?.bg || 'bg-slate-800')}>
|
||||||
<div>
|
<Icon className={cn("w-5 h-5", service?.color || 'text-slate-400')} />
|
||||||
<p className="text-white font-medium capitalize">{service?.label || bill.type}</p>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
<div>
|
||||||
<p className="text-xs text-slate-500 capitalize">{new Date(bill.date).toLocaleDateString('es-AR', { dateStyle: 'long' })}</p>
|
<p className="text-white font-medium capitalize">{service?.label || bill.type}</p>
|
||||||
{bill.usage && (
|
<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>
|
||||||
<span className="hidden sm:inline text-slate-700">•</span>
|
{bill.usage && (
|
||||||
<p className="text-xs text-slate-400">
|
<>
|
||||||
Consumo: <span className="text-slate-300 font-medium">{bill.usage} {bill.unit}</span>
|
<span className="hidden sm:inline text-slate-700">•</span>
|
||||||
</p>
|
<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>
|
||||||
<div className="text-right">
|
</div>
|
||||||
<p className="text-white font-mono font-medium">{formatCurrency(bill.amount)}</p>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="text-right">
|
||||||
<p className="text-xs text-slate-500 uppercase">{bill.period}</p>
|
<p className="text-white font-mono font-medium">{formatCurrency(bill.amount)}</p>
|
||||||
{bill.usage && bill.amount && (
|
<div className="flex flex-col items-end">
|
||||||
<p className="text-[10px] text-cyan-500/80 font-mono">
|
<p className="text-xs text-slate-500 uppercase">{bill.period}</p>
|
||||||
{formatCurrency(bill.amount / bill.usage)} / {bill.unit}
|
{bill.usage && bill.amount && (
|
||||||
</p>
|
<p className="text-[10px] text-cyan-500/80 font-mono">
|
||||||
)}
|
{formatCurrency(bill.amount / bill.usage)} / {bill.unit}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
})
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
<AddServiceModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
|
</div>
|
||||||
</div>
|
|
||||||
)
|
<AddServiceModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} />
|
||||||
}
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { 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 { cn } from '@/lib/utils'
|
||||||
import { AIServiceConfig, AppSettings } from '@/lib/types'
|
import { AIServiceConfig, AppSettings } from '@/lib/types'
|
||||||
|
import { DashboardLayout } from '@/components/layout/DashboardLayout'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -159,211 +160,213 @@ export default function SettingsPage() {
|
|||||||
if (loading) return <div className="p-8 text-center text-slate-400">Cargando configuración...</div>
|
if (loading) return <div className="p-8 text-center text-slate-400">Cargando configuración...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8 pb-10">
|
<DashboardLayout title="Configuración">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8 pb-10">
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Configuración</h1>
|
<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>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={testTelegram}
|
onClick={handleSave}
|
||||||
disabled={testingTelegram || !settings.telegram.botToken || !settings.telegram.chatId}
|
disabled={saving}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{testingTelegram ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
<Save size={18} />
|
||||||
Probar Envío
|
{saving ? 'Guardando...' : 'Guardar Cambios'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6 bg-slate-900 border border-slate-800 rounded-xl">
|
{message && (
|
||||||
<div className="space-y-2">
|
<div className={cn(
|
||||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
"p-4 rounded-lg text-sm font-medium border flex items-center gap-2 animate-in fade-in slide-in-from-top-2",
|
||||||
<Key size={12} /> Bot Token
|
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"
|
||||||
</label>
|
)}>
|
||||||
<input
|
{message.type === 'success' ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
|
||||||
type="text"
|
{message.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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Telegram Configuration */}
|
||||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
<section className="space-y-4">
|
||||||
<MessageSquare size={12} /> Chat ID
|
<div className="flex items-center justify-between text-white border-b border-slate-800 pb-2">
|
||||||
</label>
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<Bot className="text-cyan-400" />
|
||||||
type="text"
|
<h2 className="text-lg font-semibold">Telegram Bot</h2>
|
||||||
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>
|
</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>
|
||||||
|
|
||||||
{settings.aiProviders.map((provider, index) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6 bg-slate-900 border border-slate-800 rounded-xl">
|
||||||
<div key={provider.id} className="p-6 bg-slate-900 border border-slate-800 rounded-xl relative group">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||||
<h3 className="text-sm font-semibold text-slate-300 bg-slate-950 inline-block px-3 py-1 rounded-md border border-slate-800">
|
<Key size={12} /> Bot Token
|
||||||
Provider #{index + 1}
|
</label>
|
||||||
</h3>
|
<input
|
||||||
<div className="flex gap-2">
|
type="text"
|
||||||
<button
|
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
onClick={() => testAI(provider)}
|
value={settings.telegram.botToken}
|
||||||
disabled={testingAI === provider.id || !provider.endpoint || !provider.token || !provider.model}
|
onChange={(e) => setSettings({ ...settings, telegram: { ...settings.telegram, botToken: e.target.value } })}
|
||||||
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")}
|
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"
|
||||||
title="Verificar conexión"
|
/>
|
||||||
>
|
<p className="text-[10px] text-slate-500">El token que te da @BotFather.</p>
|
||||||
{testingAI === provider.id ? <Loader2 size={12} className="animate-spin" /> : <LinkIcon size={12} />}
|
</div>
|
||||||
Test
|
|
||||||
</button>
|
<div className="space-y-2">
|
||||||
<button
|
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||||
onClick={() => removeProvider(provider.id)}
|
<MessageSquare size={12} /> Chat ID
|
||||||
className="text-slate-500 hover:text-red-400 transition-colors p-1.5 hover:bg-red-500/10 rounded-lg"
|
</label>
|
||||||
title="Eliminar"
|
<input
|
||||||
>
|
type="text"
|
||||||
<Trash2 size={16} />
|
placeholder="123456789"
|
||||||
</button>
|
value={settings.telegram.chatId}
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
{settings.aiProviders.map((provider, index) => (
|
||||||
<div className="space-y-2">
|
<div key={provider.id} className="p-6 bg-slate-900 border border-slate-800 rounded-xl relative group">
|
||||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Nombre</label>
|
<div className="flex justify-between items-start mb-4">
|
||||||
<input
|
<h3 className="text-sm font-semibold text-slate-300 bg-slate-950 inline-block px-3 py-1 rounded-md border border-slate-800">
|
||||||
type="text"
|
Provider #{index + 1}
|
||||||
placeholder="Ej: MiniMax, Z.ai"
|
</h3>
|
||||||
value={provider.name}
|
<div className="flex gap-2">
|
||||||
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
|
<button
|
||||||
onClick={() => detectModels(provider)}
|
onClick={() => testAI(provider)}
|
||||||
disabled={detectingModels === provider.id || !provider.endpoint || !provider.token}
|
disabled={testingAI === provider.id || !provider.endpoint || !provider.token || !provider.model}
|
||||||
className="text-[10px] flex items-center gap-1 text-cyan-400 hover:text-cyan-300 disabled:opacity-50"
|
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"
|
||||||
>
|
>
|
||||||
{detectingModels === provider.id ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
|
{testingAI === provider.id ? <Loader2 size={12} className="animate-spin" /> : <LinkIcon size={12} />}
|
||||||
Auto Detectar
|
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>
|
</button>
|
||||||
</div>
|
</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 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>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
</div>
|
</DashboardLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
83
components/DataSync.tsx
Normal file
83
components/DataSync.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useFinanzasStore } from '@/lib/store';
|
||||||
|
|
||||||
|
export function DataSync() {
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function init() {
|
||||||
|
// Prevent double init in StrictMode
|
||||||
|
if (initialized.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sync');
|
||||||
|
// If 401 (unauthorized), stop here. User needs to login.
|
||||||
|
if (res.status === 401) return;
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const serverData = await res.json();
|
||||||
|
|
||||||
|
// Comprehensive check if server has ANY data
|
||||||
|
const hasServerData =
|
||||||
|
(serverData.fixedDebts?.length ?? 0) > 0 ||
|
||||||
|
(serverData.variableDebts?.length ?? 0) > 0 ||
|
||||||
|
(serverData.creditCards?.length ?? 0) > 0 ||
|
||||||
|
(serverData.incomes?.length ?? 0) > 0 ||
|
||||||
|
(serverData.serviceBills?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasServerData) {
|
||||||
|
console.log("Sync: Hydrating from Server");
|
||||||
|
useFinanzasStore.setState(serverData);
|
||||||
|
} else {
|
||||||
|
// Server is empty. If we have local data, push it.
|
||||||
|
// But verify we actually have local data worth pushing to avoid overwriting with empty defaults unnecessarily
|
||||||
|
const localState = useFinanzasStore.getState();
|
||||||
|
const hasLocalData =
|
||||||
|
localState.fixedDebts.length > 0 ||
|
||||||
|
localState.variableDebts.length > 0 ||
|
||||||
|
localState.creditCards.length > 0 ||
|
||||||
|
localState.incomes.length > 0;
|
||||||
|
|
||||||
|
if (hasLocalData) {
|
||||||
|
console.log("Sync: Server empty, pushing Local Data");
|
||||||
|
syncToServer(localState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Sync init error", e);
|
||||||
|
} finally {
|
||||||
|
// Mark as initialized so subsequent changes trigger sync
|
||||||
|
initialized.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync on change
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = useFinanzasStore.subscribe((state) => {
|
||||||
|
// Only sync to server if we have finished initialization/hydration
|
||||||
|
if (initialized.current) {
|
||||||
|
syncToServer(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsub();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
function syncToServer(state: any) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
fetch('/api/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(state)
|
||||||
|
}).catch(e => console.error("Sync error", e));
|
||||||
|
}, 2000); // Debounce 2s
|
||||||
|
}
|
||||||
60
components/layout/DashboardLayout.tsx
Normal file
60
components/layout/DashboardLayout.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode, useEffect } from 'react'
|
||||||
|
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
||||||
|
import { useSidebar } from '@/app/providers'
|
||||||
|
import { useAlerts } from '@/components/alerts'
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLayout({ children, title }: DashboardLayoutProps) {
|
||||||
|
const { isOpen, toggle, close, open } = useSidebar()
|
||||||
|
const { unreadCount } = useAlerts()
|
||||||
|
|
||||||
|
// Ensure sidebar is open on desktop mount
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (window.innerWidth >= 1024) {
|
||||||
|
open()
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
handleResize()
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
}, [open, close])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-slate-950">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={close}
|
||||||
|
unreadAlertsCount={unreadCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content wrapper */}
|
||||||
|
<div className="flex flex-1 flex-col lg:ml-0 min-w-0">
|
||||||
|
{/* Header */}
|
||||||
|
<Header onMenuClick={toggle} title={title} />
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
|
||||||
|
<div className="mx-auto max-w-7xl h-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation */}
|
||||||
|
<MobileNav unreadAlertsCount={unreadCount} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Settings,
|
Settings,
|
||||||
|
TrendingUp,
|
||||||
X,
|
X,
|
||||||
|
LogOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Logo } from './Logo';
|
import { Logo } from './Logo';
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||||
|
{ name: 'Ingresos', href: '/incomes', icon: TrendingUp },
|
||||||
{ name: 'Deudas', href: '/debts', icon: Wallet },
|
{ name: 'Deudas', href: '/debts', icon: Wallet },
|
||||||
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
|
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
|
||||||
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
|
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
|
||||||
@@ -36,6 +40,7 @@ export function Sidebar({
|
|||||||
unreadAlertsCount = 0,
|
unreadAlertsCount = 0,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
if (href === '/') {
|
if (href === '/') {
|
||||||
@@ -44,6 +49,18 @@ export function Sidebar({
|
|||||||
return pathname.startsWith(href);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
@@ -110,6 +127,16 @@ export function Sidebar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
1
dist/404.html
vendored
Normal file
1
dist/404.html
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/4SrcMtBIfNF-pqHyUpitS/_buildManifest.js
vendored
Normal file
1
dist/_next/static/4SrcMtBIfNF-pqHyUpitS/_buildManifest.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
self.__BUILD_MANIFEST={__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/_error":["static/chunks/pages/_error-7ba65e1336b92748.js"],sortedPages:["/_app","/_error"]},self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();
|
||||||
1
dist/_next/static/4SrcMtBIfNF-pqHyUpitS/_ssgManifest.js
vendored
Normal file
1
dist/_next/static/4SrcMtBIfNF-pqHyUpitS/_ssgManifest.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||||
2
dist/_next/static/chunks/117-14b82d0a7edfd352.js
vendored
Normal file
2
dist/_next/static/chunks/117-14b82d0a7edfd352.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/489-4b7fe4fae3b6ef80.js
vendored
Normal file
1
dist/_next/static/chunks/489-4b7fe4fae3b6ef80.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/697-93a9cd29100d0d50.js
vendored
Normal file
1
dist/_next/static/chunks/697-93a9cd29100d0d50.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/71-fccb418814321416.js
vendored
Normal file
1
dist/_next/static/chunks/71-fccb418814321416.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[71],{2489:function(e,t,r){r.d(t,{Z:function(){return n}});let n=(0,r(8755).Z)("x",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]])},4147:function(e,t,r){let n;r.d(t,{Z:function(){return u}});var o={randomUUID:"undefined"!=typeof crypto&&crypto.randomUUID&&crypto.randomUUID.bind(crypto)};let a=new Uint8Array(16),i=[];for(let e=0;e<256;++e)i.push((e+256).toString(16).slice(1));var u=function(e,t,r){return!o.randomUUID||t||e?function(e,t,r){let o=(e=e||{}).random??e.rng?.()??function(){if(!n){if("undefined"==typeof crypto||!crypto.getRandomValues)throw Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");n=crypto.getRandomValues.bind(crypto)}return n(a)}();if(o.length<16)throw Error("Random bytes length must be >= 16");if(o[6]=15&o[6]|64,o[8]=63&o[8]|128,t){if((r=r||0)<0||r+16>t.length)throw RangeError(`UUID byte range ${r}:${r+15} is out of buffer bounds`);for(let e=0;e<16;++e)t[r+e]=o[e];return t}return function(e,t=0){return(i[e[t+0]]+i[e[t+1]]+i[e[t+2]]+i[e[t+3]]+"-"+i[e[t+4]]+i[e[t+5]]+"-"+i[e[t+6]]+i[e[t+7]]+"-"+i[e[t+8]]+i[e[t+9]]+"-"+i[e[t+10]]+i[e[t+11]]+i[e[t+12]]+i[e[t+13]]+i[e[t+14]]+i[e[t+15]]).toLowerCase()}(o)}(e,t,r):o.randomUUID()}},6885:function(e,t,r){r.d(t,{tJ:function(){return o}});let n=e=>t=>{try{let r=e(t);if(r instanceof Promise)return r;return{then:e=>n(e)(r),catch(e){return this}}}catch(e){return{then(e){return this},catch:t=>n(t)(e)}}},o=(e,t)=>(r,o,a)=>{let i,u={storage:function(e,t){let r;try{r=e()}catch(e){return}return{getItem:e=>{var t;let n=e=>null===e?null:JSON.parse(e,void 0),o=null!=(t=r.getItem(e))?t:null;return o instanceof Promise?o.then(n):n(o)},setItem:(e,t)=>r.setItem(e,JSON.stringify(t,void 0)),removeItem:e=>r.removeItem(e)}}(()=>localStorage),partialize:e=>e,version:0,merge:(e,t)=>({...t,...e}),...t},l=!1,s=0,c=new Set,d=new Set,f=u.storage;if(!f)return e((...e)=>{console.warn(`[zustand persist middleware] Unable to update item '${u.name}', the given storage is currently unavailable.`),r(...e)},o,a);let m=()=>{let e=u.partialize({...o()});return f.setItem(u.name,{state:e,version:u.version})},g=a.setState;a.setState=(e,t)=>(g(e,t),m());let h=e((...e)=>(r(...e),m()),o,a);a.getInitialState=()=>h;let p=()=>{var e,t;if(!f)return;let a=++s;l=!1,c.forEach(e=>{var t;return e(null!=(t=o())?t:h)});let g=(null==(t=u.onRehydrateStorage)?void 0:t.call(u,null!=(e=o())?e:h))||void 0;return n(f.getItem.bind(f))(u.name).then(e=>{if(e){if("number"!=typeof e.version||e.version===u.version)return[!1,e.state];if(u.migrate){let t=u.migrate(e.state,e.version);return t instanceof Promise?t.then(e=>[!0,e]):[!0,t]}console.error("State loaded from storage couldn't be migrated since no migrate function was provided")}return[!1,void 0]}).then(e=>{var t;if(a!==s)return;let[n,l]=e;if(r(i=u.merge(l,null!=(t=o())?t:h),!0),n)return m()}).then(()=>{a===s&&(null==g||g(i,void 0),i=o(),l=!0,d.forEach(e=>e(i)))}).catch(e=>{a===s&&(null==g||g(void 0,e))})};return a.persist={setOptions:e=>{u={...u,...e},e.storage&&(f=e.storage)},clearStorage:()=>{null==f||f.removeItem(u.name)},getOptions:()=>u,rehydrate:()=>p(),hasHydrated:()=>l,onHydrate:e=>(c.add(e),()=>{c.delete(e)}),onFinishHydration:e=>(d.add(e),()=>{d.delete(e)})},u.skipHydration||p(),i||h}},3011:function(e,t,r){r.d(t,{U:function(){return l}});var n=r(2265);let o=e=>{let t;let r=new Set,n=(e,n)=>{let o="function"==typeof e?e(t):e;if(!Object.is(o,t)){let e=t;t=(null!=n?n:"object"!=typeof o||null===o)?o:Object.assign({},t,o),r.forEach(r=>r(t,e))}},o=()=>t,a={setState:n,getState:o,getInitialState:()=>i,subscribe:e=>(r.add(e),()=>r.delete(e))},i=t=e(n,o,a);return a},a=e=>e?o(e):o,i=e=>e,u=e=>{let t=a(e),r=e=>(function(e,t=i){let r=n.useSyncExternalStore(e.subscribe,n.useCallback(()=>t(e.getState()),[e,t]),n.useCallback(()=>t(e.getInitialState()),[e,t]));return n.useDebugValue(r),r})(t,e);return Object.assign(r,t),r},l=e=>e?u(e):u}}]);
|
||||||
1
dist/_next/static/chunks/796-9d56fd2c469c90a6.js
vendored
Normal file
1
dist/_next/static/chunks/796-9d56fd2c469c90a6.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/838-3b22f3c21887b523.js
vendored
Normal file
1
dist/_next/static/chunks/838-3b22f3c21887b523.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/app/_not-found/page-e30cecccd190b7d4.js
vendored
Normal file
1
dist/_next/static/chunks/app/_not-found/page-e30cecccd190b7d4.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[409],{7589:function(e,t,n){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_not-found/page",function(){return n(3634)}])},3634:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return s}}),n(7043);let i=n(7437);n(2265);let o={fontFamily:'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',height:"100vh",textAlign:"center",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center"},l={display:"inline-block"},r={display:"inline-block",margin:"0 20px 0 0",padding:"0 23px 0 0",fontSize:24,fontWeight:500,verticalAlign:"top",lineHeight:"49px"},d={fontSize:14,fontWeight:400,lineHeight:"49px",margin:0};function s(){return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)("title",{children:"404: This page could not be found."}),(0,i.jsx)("div",{style:o,children:(0,i.jsxs)("div",{children:[(0,i.jsx)("style",{dangerouslySetInnerHTML:{__html:"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}),(0,i.jsx)("h1",{className:"next-error-h1",style:r,children:"404"}),(0,i.jsx)("div",{style:l,children:(0,i.jsx)("h2",{style:d,children:"This page could not be found."})})]})})]})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)}},function(e){e.O(0,[971,117,744],function(){return e(e.s=7589)}),_N_E=e.O()}]);
|
||||||
1
dist/_next/static/chunks/app/alerts/page-6f170ee2c75a0961.js
vendored
Normal file
1
dist/_next/static/chunks/app/alerts/page-6f170ee2c75a0961.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[78],{4033:function(e,s,n){Promise.resolve().then(n.bind(n,5949))},5949:function(e,s,n){"use strict";n.r(s),n.d(s,{default:function(){return c}});var l=n(7437),t=n(553),i=n(3263),a=n(9294);let r=(0,n(8755).Z)("refresh-cw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);function c(){let{isOpen:e,toggle:s,close:n}=(0,a.A)(),{regenerateAlerts:c,dismissAll:o,unreadCount:u}=(0,i.Z7)();return(0,l.jsxs)("div",{className:"min-h-screen bg-slate-950",children:[(0,l.jsx)(t.YE,{isOpen:e,onClose:n,unreadAlertsCount:u}),(0,l.jsxs)("div",{className:"lg:ml-64 min-h-screen flex flex-col",children:[(0,l.jsx)(t.h4,{onMenuClick:s,title:"Alertas"}),(0,l.jsx)("main",{className:"flex-1 p-4 md:p-6 lg:p-8 pb-20 lg:pb-8",children:(0,l.jsxs)("div",{className:"max-w-4xl mx-auto",children:[(0,l.jsxs)("div",{className:"flex flex-wrap gap-3 mb-6",children:[(0,l.jsxs)("button",{onClick:()=>{c()},className:"inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20",children:[(0,l.jsx)(r,{className:"h-4 w-4"}),"Regenerar Alertas"]}),(0,l.jsx)("button",{onClick:()=>{o()},className:"inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-slate-500/20",children:"Limpiar Todas"})]}),(0,l.jsx)("div",{className:"w-full",children:(0,l.jsx)(i.KG,{})})]})}),(0,l.jsx)(t.zM,{unreadAlertsCount:u})]})]})}}},function(e){e.O(0,[697,71,796,489,971,117,744],function(){return e(e.s=4033)}),_N_E=e.O()}]);
|
||||||
1
dist/_next/static/chunks/app/budget/page-1c1157916eee3b45.js
vendored
Normal file
1
dist/_next/static/chunks/app/budget/page-1c1157916eee3b45.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/app/cards/page-a9843d3be56b680d.js
vendored
Normal file
1
dist/_next/static/chunks/app/cards/page-a9843d3be56b680d.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/app/debts/page-28aedff94a342b70.js
vendored
Normal file
1
dist/_next/static/chunks/app/debts/page-28aedff94a342b70.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/app/layout-639440f4d8b9b6b3.js
vendored
Normal file
1
dist/_next/static/chunks/app/layout-639440f4d8b9b6b3.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{1556:function(e,n,t){Promise.resolve().then(t.bind(t,9294)),Promise.resolve().then(t.t.bind(t,8925,23)),Promise.resolve().then(t.t.bind(t,7960,23))},9294:function(e,n,t){"use strict";t.d(n,{A:function(){return u},Providers:function(){return s}});var r=t(7437),o=t(2265);let i=(0,o.createContext)(void 0);function s(e){let{children:n}=e,[t,s]=(0,o.useState)(!0);return(0,r.jsx)(i.Provider,{value:{isOpen:t,toggle:()=>s(e=>!e),close:()=>s(!1),open:()=>s(!0)},children:n})}function u(){let e=(0,o.useContext)(i);if(void 0===e)throw Error("useSidebar must be used within a Providers");return e}},7960:function(){},8925:function(e){e.exports={style:{fontFamily:"'__Inter_f367f3', '__Inter_Fallback_f367f3'",fontStyle:"normal"},className:"__className_f367f3",variable:"__variable_f367f3"}}},function(e){e.O(0,[832,971,117,744],function(){return e(e.s=1556)}),_N_E=e.O()}]);
|
||||||
1
dist/_next/static/chunks/app/login/page-6be400e8521677b4.js
vendored
Normal file
1
dist/_next/static/chunks/app/login/page-6be400e8521677b4.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/app/page-c63080d1806e3966.js
vendored
Normal file
1
dist/_next/static/chunks/app/page-c63080d1806e3966.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/app/services/page-624950d2fabe2b7b.js
vendored
Normal file
1
dist/_next/static/chunks/app/services/page-624950d2fabe2b7b.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/app/settings/page-88d3fd0faab66996.js
vendored
Normal file
1
dist/_next/static/chunks/app/settings/page-88d3fd0faab66996.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/fd9d1056-307a36020502e7d7.js
vendored
Normal file
1
dist/_next/static/chunks/fd9d1056-307a36020502e7d7.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/framework-f66176bb897dc684.js
vendored
Normal file
1
dist/_next/static/chunks/framework-f66176bb897dc684.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/main-478a66ed427fba46.js
vendored
Normal file
1
dist/_next/static/chunks/main-478a66ed427fba46.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/_next/static/chunks/main-app-4119cbfe984dfca9.js
vendored
Normal file
1
dist/_next/static/chunks/main-app-4119cbfe984dfca9.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{2300:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(2300)}),_N_E=e.O()}]);
|
||||||
1
dist/_next/static/chunks/pages/_app-72b849fbd24ac258.js
vendored
Normal file
1
dist/_next/static/chunks/pages/_app-72b849fbd24ac258.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[888],{1597:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_app",function(){return u(8141)}])}},function(n){var _=function(_){return n(n.s=_)};n.O(0,[774,179],function(){return _(1597),_(7253)}),_N_E=n.O()}]);
|
||||||
1
dist/_next/static/chunks/pages/_error-7ba65e1336b92748.js
vendored
Normal file
1
dist/_next/static/chunks/pages/_error-7ba65e1336b92748.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[820],{1981:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_error",function(){return u(8529)}])}},function(n){n.O(0,[888,774,179],function(){return n(n.s=1981)}),_N_E=n.O()}]);
|
||||||
1
dist/_next/static/chunks/webpack-785ddfc5aaaa81eb.js
vendored
Normal file
1
dist/_next/static/chunks/webpack-785ddfc5aaaa81eb.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
!function(){"use strict";var e,t,n,r,o,u,i,c,f,a={},l={};function s(e){var t=l[e];if(void 0!==t)return t.exports;var n=l[e]={exports:{}},r=!0;try{a[e].call(n.exports,n,n.exports,s),r=!1}finally{r&&delete l[e]}return n.exports}s.m=a,e=[],s.O=function(t,n,r,o){if(n){o=o||0;for(var u=e.length;u>0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[n,r,o];return}for(var i=1/0,u=0;u<e.length;u++){for(var n=e[u][0],r=e[u][1],o=e[u][2],c=!0,f=0;f<n.length;f++)i>=o&&Object.keys(s.O).every(function(e){return s.O[e](n[f])})?n.splice(f--,1):(c=!1,o<i&&(i=o));if(c){e.splice(u--,1);var a=r();void 0!==a&&(t=a)}}return t},s.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return s.d(t,{a:t}),t},n=Object.getPrototypeOf?function(e){return Object.getPrototypeOf(e)}:function(e){return e.__proto__},s.t=function(e,r){if(1&r&&(e=this(e)),8&r||"object"==typeof e&&e&&(4&r&&e.__esModule||16&r&&"function"==typeof e.then))return e;var o=Object.create(null);s.r(o);var u={};t=t||[null,n({}),n([]),n(n)];for(var i=2&r&&e;"object"==typeof i&&!~t.indexOf(i);i=n(i))Object.getOwnPropertyNames(i).forEach(function(t){u[t]=function(){return e[t]}});return u.default=function(){return e},s.d(o,u),o},s.d=function(e,t){for(var n in t)s.o(t,n)&&!s.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},s.f={},s.e=function(e){return Promise.all(Object.keys(s.f).reduce(function(t,n){return s.f[n](e,t),t},[]))},s.u=function(e){},s.miniCssF=function(e){},s.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),s.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r={},o="_N_E:",s.l=function(e,t,n,u){if(r[e]){r[e].push(t);return}if(void 0!==n)for(var i,c,f=document.getElementsByTagName("script"),a=0;a<f.length;a++){var l=f[a];if(l.getAttribute("src")==e||l.getAttribute("data-webpack")==o+n){i=l;break}}i||(c=!0,(i=document.createElement("script")).charset="utf-8",i.timeout=120,s.nc&&i.setAttribute("nonce",s.nc),i.setAttribute("data-webpack",o+n),i.src=s.tu(e)),r[e]=[t];var d=function(t,n){i.onerror=i.onload=null,clearTimeout(p);var o=r[e];if(delete r[e],i.parentNode&&i.parentNode.removeChild(i),o&&o.forEach(function(e){return e(n)}),t)return t(n)},p=setTimeout(d.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=d.bind(null,i.onerror),i.onload=d.bind(null,i.onload),c&&document.head.appendChild(i)},s.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.tt=function(){return void 0===u&&(u={createScriptURL:function(e){return e}},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(u=trustedTypes.createPolicy("nextjs#bundler",u))),u},s.tu=function(e){return s.tt().createScriptURL(e)},s.p="/_next/",i={272:0,832:0},s.f.j=function(e,t){var n=s.o(i,e)?i[e]:void 0;if(0!==n){if(n)t.push(n[2]);else if(/^(27|83)2$/.test(e))i[e]=0;else{var r=new Promise(function(t,r){n=i[e]=[t,r]});t.push(n[2]=r);var o=s.p+s.u(e),u=Error();s.l(o,function(t){if(s.o(i,e)&&(0!==(n=i[e])&&(i[e]=void 0),n)){var r=t&&("load"===t.type?"missing":t.type),o=t&&t.target&&t.target.src;u.message="Loading chunk "+e+" failed.\n("+r+": "+o+")",u.name="ChunkLoadError",u.type=r,u.request=o,n[1](u)}},"chunk-"+e,e)}}},s.O.j=function(e){return 0===i[e]},c=function(e,t){var n,r,o=t[0],u=t[1],c=t[2],f=0;if(o.some(function(e){return 0!==i[e]})){for(n in u)s.o(u,n)&&(s.m[n]=u[n]);if(c)var a=c(s)}for(e&&e(t);f<o.length;f++)r=o[f],s.o(i,r)&&i[r]&&i[r][0](),i[r]=0;return s.O(a)},(f=self.webpackChunk_N_E=self.webpackChunk_N_E||[]).forEach(c.bind(null,0)),f.push=c.bind(null,f.push.bind(f))}();
|
||||||
3
dist/_next/static/css/9effa8aa186c096c.css
vendored
Normal file
3
dist/_next/static/css/9effa8aa186c096c.css
vendored
Normal file
File diff suppressed because one or more lines are too long
54
dist/alerts.html
vendored
Normal file
54
dist/alerts.html
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dist/alerts.txt
vendored
Normal file
8
dist/alerts.txt
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
2:I[9107,[],"ClientPageRoot"]
|
||||||
|
3:I[5949,["697","static/chunks/697-93a9cd29100d0d50.js","71","static/chunks/71-fccb418814321416.js","796","static/chunks/796-9d56fd2c469c90a6.js","489","static/chunks/489-4b7fe4fae3b6ef80.js","78","static/chunks/app/alerts/page-6f170ee2c75a0961.js"],"default",1]
|
||||||
|
4:I[4707,[],""]
|
||||||
|
5:I[6423,[],""]
|
||||||
|
6:I[9294,["185","static/chunks/app/layout-639440f4d8b9b6b3.js"],"Providers"]
|
||||||
|
0:["4SrcMtBIfNF-pqHyUpitS",[[["",{"children":["alerts",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["alerts",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","alerts","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/9effa8aa186c096c.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"es","className":"__variable_f367f3","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__className_f367f3 antialiased min-h-screen bg-slate-950 text-slate-50","children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"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"},"children":["$","div",null,{"children":[["$","style",null,{"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)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||||
|
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Finanzas Personales"}],["$","meta","3",{"name":"description","content":"Gestiona tus finanzas personales de forma inteligente"}],["$","meta","4",{"name":"keywords","content":"finanzas,presupuesto,gastos,ingresos,ahorro"}],["$","meta","5",{"name":"next-size-adjust"}]]
|
||||||
|
1:null
|
||||||
25
dist/app-build-manifest.json
vendored
25
dist/app-build-manifest.json
vendored
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": {
|
|
||||||
"/layout": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/css/app/layout.css",
|
|
||||||
"static/chunks/app/layout.js"
|
|
||||||
],
|
|
||||||
"/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/page.js"
|
|
||||||
],
|
|
||||||
"/settings/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/settings/page.js"
|
|
||||||
],
|
|
||||||
"/_not-found/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/_not-found/page.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
dist/budget.html
vendored
Normal file
54
dist/budget.html
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dist/budget.txt
vendored
Normal file
8
dist/budget.txt
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
2:I[9107,[],"ClientPageRoot"]
|
||||||
|
3:I[7268,["697","static/chunks/697-93a9cd29100d0d50.js","71","static/chunks/71-fccb418814321416.js","796","static/chunks/796-9d56fd2c469c90a6.js","489","static/chunks/489-4b7fe4fae3b6ef80.js","379","static/chunks/app/budget/page-1c1157916eee3b45.js"],"default",1]
|
||||||
|
4:I[4707,[],""]
|
||||||
|
5:I[6423,[],""]
|
||||||
|
6:I[9294,["185","static/chunks/app/layout-639440f4d8b9b6b3.js"],"Providers"]
|
||||||
|
0:["4SrcMtBIfNF-pqHyUpitS",[[["",{"children":["budget",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["budget",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","budget","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/9effa8aa186c096c.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"es","className":"__variable_f367f3","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__className_f367f3 antialiased min-h-screen bg-slate-950 text-slate-50","children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"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"},"children":["$","div",null,{"children":[["$","style",null,{"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)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||||
|
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Finanzas Personales"}],["$","meta","3",{"name":"description","content":"Gestiona tus finanzas personales de forma inteligente"}],["$","meta","4",{"name":"keywords","content":"finanzas,presupuesto,gastos,ingresos,ahorro"}],["$","meta","5",{"name":"next-size-adjust"}]]
|
||||||
|
1:null
|
||||||
19
dist/build-manifest.json
vendored
19
dist/build-manifest.json
vendored
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"polyfillFiles": [
|
|
||||||
"static/chunks/polyfills.js"
|
|
||||||
],
|
|
||||||
"devFiles": [],
|
|
||||||
"ampDevFiles": [],
|
|
||||||
"lowPriorityFiles": [
|
|
||||||
"static/development/_buildManifest.js",
|
|
||||||
"static/development/_ssgManifest.js"
|
|
||||||
],
|
|
||||||
"rootMainFiles": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js"
|
|
||||||
],
|
|
||||||
"pages": {
|
|
||||||
"/_app": []
|
|
||||||
},
|
|
||||||
"ampFirstPages": []
|
|
||||||
}
|
|
||||||
BIN
dist/cache/webpack/client-development/0.pack.gz
vendored
BIN
dist/cache/webpack/client-development/0.pack.gz
vendored
Binary file not shown.
BIN
dist/cache/webpack/client-development/1.pack.gz
vendored
BIN
dist/cache/webpack/client-development/1.pack.gz
vendored
Binary file not shown.
BIN
dist/cache/webpack/client-development/2.pack.gz
vendored
BIN
dist/cache/webpack/client-development/2.pack.gz
vendored
Binary file not shown.
BIN
dist/cache/webpack/client-development/index.pack.gz
vendored
BIN
dist/cache/webpack/client-development/index.pack.gz
vendored
Binary file not shown.
Binary file not shown.
BIN
dist/cache/webpack/server-development/0.pack.gz
vendored
BIN
dist/cache/webpack/server-development/0.pack.gz
vendored
Binary file not shown.
BIN
dist/cache/webpack/server-development/1.pack.gz
vendored
BIN
dist/cache/webpack/server-development/1.pack.gz
vendored
Binary file not shown.
BIN
dist/cache/webpack/server-development/2.pack.gz
vendored
BIN
dist/cache/webpack/server-development/2.pack.gz
vendored
Binary file not shown.
BIN
dist/cache/webpack/server-development/3.pack.gz
vendored
BIN
dist/cache/webpack/server-development/3.pack.gz
vendored
Binary file not shown.
BIN
dist/cache/webpack/server-development/index.pack.gz
vendored
BIN
dist/cache/webpack/server-development/index.pack.gz
vendored
Binary file not shown.
Binary file not shown.
54
dist/cards.html
vendored
Normal file
54
dist/cards.html
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dist/cards.txt
vendored
Normal file
8
dist/cards.txt
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
2:I[9107,[],"ClientPageRoot"]
|
||||||
|
3:I[3234,["697","static/chunks/697-93a9cd29100d0d50.js","71","static/chunks/71-fccb418814321416.js","796","static/chunks/796-9d56fd2c469c90a6.js","489","static/chunks/489-4b7fe4fae3b6ef80.js","137","static/chunks/app/cards/page-a9843d3be56b680d.js"],"default",1]
|
||||||
|
4:I[4707,[],""]
|
||||||
|
5:I[6423,[],""]
|
||||||
|
6:I[9294,["185","static/chunks/app/layout-639440f4d8b9b6b3.js"],"Providers"]
|
||||||
|
0:["4SrcMtBIfNF-pqHyUpitS",[[["",{"children":["cards",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["cards",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","cards","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/9effa8aa186c096c.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"es","className":"__variable_f367f3","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__className_f367f3 antialiased min-h-screen bg-slate-950 text-slate-50","children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"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"},"children":["$","div",null,{"children":[["$","style",null,{"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)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||||
|
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Finanzas Personales"}],["$","meta","3",{"name":"description","content":"Gestiona tus finanzas personales de forma inteligente"}],["$","meta","4",{"name":"keywords","content":"finanzas,presupuesto,gastos,ingresos,ahorro"}],["$","meta","5",{"name":"next-size-adjust"}]]
|
||||||
|
1:null
|
||||||
54
dist/debts.html
vendored
Normal file
54
dist/debts.html
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dist/debts.txt
vendored
Normal file
8
dist/debts.txt
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
2:I[9107,[],"ClientPageRoot"]
|
||||||
|
3:I[9220,["697","static/chunks/697-93a9cd29100d0d50.js","71","static/chunks/71-fccb418814321416.js","796","static/chunks/796-9d56fd2c469c90a6.js","489","static/chunks/489-4b7fe4fae3b6ef80.js","273","static/chunks/app/debts/page-28aedff94a342b70.js"],"default",1]
|
||||||
|
4:I[4707,[],""]
|
||||||
|
5:I[6423,[],""]
|
||||||
|
6:I[9294,["185","static/chunks/app/layout-639440f4d8b9b6b3.js"],"Providers"]
|
||||||
|
0:["4SrcMtBIfNF-pqHyUpitS",[[["",{"children":["debts",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["debts",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","debts","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/9effa8aa186c096c.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"es","className":"__variable_f367f3","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__className_f367f3 antialiased min-h-screen bg-slate-950 text-slate-50","children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"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"},"children":["$","div",null,{"children":[["$","style",null,{"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)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||||
|
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Finanzas Personales"}],["$","meta","3",{"name":"description","content":"Gestiona tus finanzas personales de forma inteligente"}],["$","meta","4",{"name":"keywords","content":"finanzas,presupuesto,gastos,ingresos,ahorro"}],["$","meta","5",{"name":"next-size-adjust"}]]
|
||||||
|
1:null
|
||||||
69
dist/index.html
vendored
Normal file
69
dist/index.html
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dist/index.txt
vendored
Normal file
8
dist/index.txt
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
2:I[9107,[],"ClientPageRoot"]
|
||||||
|
3:I[291,["697","static/chunks/697-93a9cd29100d0d50.js","71","static/chunks/71-fccb418814321416.js","796","static/chunks/796-9d56fd2c469c90a6.js","838","static/chunks/838-3b22f3c21887b523.js","489","static/chunks/489-4b7fe4fae3b6ef80.js","931","static/chunks/app/page-c63080d1806e3966.js"],"default",1]
|
||||||
|
4:I[9294,["185","static/chunks/app/layout-639440f4d8b9b6b3.js"],"Providers"]
|
||||||
|
5:I[4707,[],""]
|
||||||
|
6:I[6423,[],""]
|
||||||
|
0:["4SrcMtBIfNF-pqHyUpitS",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/9effa8aa186c096c.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"es","className":"__variable_f367f3","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__className_f367f3 antialiased min-h-screen bg-slate-950 text-slate-50","children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"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"},"children":["$","div",null,{"children":[["$","style",null,{"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)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||||
|
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Finanzas Personales"}],["$","meta","3",{"name":"description","content":"Gestiona tus finanzas personales de forma inteligente"}],["$","meta","4",{"name":"keywords","content":"finanzas,presupuesto,gastos,ingresos,ahorro"}],["$","meta","5",{"name":"next-size-adjust"}]]
|
||||||
|
1:null
|
||||||
1
dist/login.html
vendored
Normal file
1
dist/login.html
vendored
Normal file
File diff suppressed because one or more lines are too long
8
dist/login.txt
vendored
Normal file
8
dist/login.txt
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
2:I[9107,[],"ClientPageRoot"]
|
||||||
|
3:I[6374,["626","static/chunks/app/login/page-6be400e8521677b4.js"],"default",1]
|
||||||
|
4:I[4707,[],""]
|
||||||
|
5:I[6423,[],""]
|
||||||
|
6:I[9294,["185","static/chunks/app/layout-639440f4d8b9b6b3.js"],"Providers"]
|
||||||
|
0:["4SrcMtBIfNF-pqHyUpitS",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/9effa8aa186c096c.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"es","className":"__variable_f367f3","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__className_f367f3 antialiased min-h-screen bg-slate-950 text-slate-50","children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"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"},"children":["$","div",null,{"children":[["$","style",null,{"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)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
|
||||||
|
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Finanzas Personales"}],["$","meta","3",{"name":"description","content":"Gestiona tus finanzas personales de forma inteligente"}],["$","meta","4",{"name":"keywords","content":"finanzas,presupuesto,gastos,ingresos,ahorro"}],["$","meta","5",{"name":"next-size-adjust"}]]
|
||||||
|
1:null
|
||||||
1
dist/package.json
vendored
1
dist/package.json
vendored
@@ -1 +0,0 @@
|
|||||||
{"type": "commonjs"}
|
|
||||||
1
dist/react-loadable-manifest.json
vendored
1
dist/react-loadable-manifest.json
vendored
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
7
dist/server/app-paths-manifest.json
vendored
7
dist/server/app-paths-manifest.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"/page": "app/page.js",
|
|
||||||
"/api/settings/route": "app/api/settings/route.js",
|
|
||||||
"/settings/page": "app/settings/page.js",
|
|
||||||
"/api/test/ai/route": "app/api/test/ai/route.js",
|
|
||||||
"/api/proxy/models/route": "app/api/proxy/models/route.js"
|
|
||||||
}
|
|
||||||
155
dist/server/app/_not-found/page.js
vendored
155
dist/server/app/_not-found/page.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
66
dist/server/app/api/proxy/models/route.js
vendored
66
dist/server/app/api/proxy/models/route.js
vendored
File diff suppressed because one or more lines are too long
86
dist/server/app/api/settings/route.js
vendored
86
dist/server/app/api/settings/route.js
vendored
File diff suppressed because one or more lines are too long
66
dist/server/app/api/test/ai/route.js
vendored
66
dist/server/app/api/test/ai/route.js
vendored
File diff suppressed because one or more lines are too long
557
dist/server/app/page.js
vendored
557
dist/server/app/page.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
205
dist/server/app/settings/page.js
vendored
205
dist/server/app/settings/page.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
|
||||||
21
dist/server/middleware-build-manifest.js
vendored
21
dist/server/middleware-build-manifest.js
vendored
@@ -1,21 +0,0 @@
|
|||||||
self.__BUILD_MANIFEST = {
|
|
||||||
"polyfillFiles": [
|
|
||||||
"static/chunks/polyfills.js"
|
|
||||||
],
|
|
||||||
"devFiles": [],
|
|
||||||
"ampDevFiles": [],
|
|
||||||
"lowPriorityFiles": [],
|
|
||||||
"rootMainFiles": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js"
|
|
||||||
],
|
|
||||||
"pages": {
|
|
||||||
"/_app": []
|
|
||||||
},
|
|
||||||
"ampFirstPages": []
|
|
||||||
};
|
|
||||||
self.__BUILD_MANIFEST.lowPriorityFiles = [
|
|
||||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
|
||||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
|
||||||
|
|
||||||
];
|
|
||||||
6
dist/server/middleware-manifest.json
vendored
6
dist/server/middleware-manifest.json
vendored
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"middleware": {},
|
|
||||||
"functions": {},
|
|
||||||
"sortedMiddleware": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
|
||||||
1
dist/server/next-font-manifest.js
vendored
1
dist/server/next-font-manifest.js
vendored
@@ -1 +0,0 @@
|
|||||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
|
||||||
1
dist/server/next-font-manifest.json
vendored
1
dist/server/next-font-manifest.json
vendored
@@ -1 +0,0 @@
|
|||||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
|
||||||
1
dist/server/pages-manifest.json
vendored
1
dist/server/pages-manifest.json
vendored
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user