feat: initial commit - finanzas app
Complete personal finance management application with: - Dashboard with financial metrics and alerts - Credit card management and payments - Fixed and variable debt tracking - Monthly budget planning - Intelligent alert system - Responsive design with Tailwind CSS Tech stack: Next.js 14, TypeScript, Zustand, Recharts 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Local env files
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
161
README.md
Normal file
161
README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 💰 Finanzas - Gestor Personal de Finanzas
|
||||
|
||||
Una aplicación moderna y completa para la gestión de finanzas personales, construida con Next.js y TypeScript. Controla tus deudas, tarjetas de crédito, presupuestos y mantente al día con alertas inteligentes.
|
||||
|
||||

|
||||
|
||||
## ✨ Características Principales
|
||||
|
||||
### 🏠 Dashboard Central
|
||||
- **Resumen financiero** con métricas clave
|
||||
- **Actividad reciente** y tendencias
|
||||
- **Acciones rápidas** para agregar gastos y pagos
|
||||
- **Alertas inteligentes** personalizadas
|
||||
|
||||
### 💳 Gestión de Tarjetas de Crédito
|
||||
- Múltiples tarjetas con límites y saldos
|
||||
- Registro de pagos y compras
|
||||
- Seguimiento de cuotas e instalaciones
|
||||
- Alertas de cierre y vencimiento
|
||||
|
||||
### 📊 Control de Deudas
|
||||
- **Deudas fijas**: Alquiler, servicios, suscripciones
|
||||
- **Deudas variables**: Compras, entretenimiento, salud
|
||||
- Categorización automática
|
||||
- Marcado de pagos realizados
|
||||
- Notas y recordatorios
|
||||
|
||||
### 💰 Presupuesto Mensual
|
||||
- Ingresos y gastos planificados
|
||||
- Metas de ahorro
|
||||
- Seguimiento en tiempo real
|
||||
- Visualización con gráficos
|
||||
|
||||
### 🚨 Sistema de Alertas
|
||||
- Alertas de vencimiento de pagos
|
||||
- Advertencias de presupuesto
|
||||
- Recordatorios de cierre de tarjetas
|
||||
- Detección de gastos inusuales
|
||||
- Notificaciones de metas de ahorro
|
||||
|
||||
### 📱 Diseño Responsivo
|
||||
- Sidebar colapsible en desktop
|
||||
- Navegación móvil intuitiva
|
||||
- Interfaz moderna con Tailwind CSS
|
||||
- Soporte completo para dispositivos móviles
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
- **[Next.js 14](https://nextjs.org/)** - Framework React con SSR/SSG
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - Tipado estático
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Estilos utilitarios
|
||||
- **[Zustand](https://github.com/pmndrs/zustand)** - Gestión de estado
|
||||
- **[Recharts](https://recharts.org/)** - Gráficos y visualizaciones
|
||||
- **[Lucide React](https://lucide.dev/)** - Iconos modernos
|
||||
|
||||
## 🚀 Instalación y Uso
|
||||
|
||||
### Prerrequisitos
|
||||
- Node.js 18+
|
||||
- npm o yarn
|
||||
|
||||
### 1. Clonar el repositorio
|
||||
```bash
|
||||
git clone https://gitea.cbcren.online/renato97/finanzas.git
|
||||
cd finanzas
|
||||
```
|
||||
|
||||
### 2. Instalar dependencias
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Ejecutar en desarrollo
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
La aplicación estará disponible en `http://localhost:3000`
|
||||
|
||||
### 4. Construir para producción
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Los archivos estáticos se generarán en el directorio `dist/`
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```
|
||||
finanzas/
|
||||
├── app/ # Rutas y páginas de Next.js
|
||||
│ ├── alerts/ # Página de alertas
|
||||
│ ├── budget/ # Página de presupuesto
|
||||
│ ├── cards/ # Página de tarjetas
|
||||
│ ├── debts/ # Página de deudas
|
||||
│ └── page.tsx # Dashboard principal
|
||||
├── components/ # Componentes reutilizables
|
||||
│ ├── alerts/ # Sistema de alertas
|
||||
│ ├── budget/ # Componentes de presupuesto
|
||||
│ ├── cards/ # Componentes de tarjetas
|
||||
│ ├── dashboard/ # Componentes del dashboard
|
||||
│ ├── debts/ # Componentes de deudas
|
||||
│ ├── layout/ # Layout y navegación
|
||||
│ └── modals/ # Modales de creación/edición
|
||||
├── lib/ # Utilidades y store
|
||||
│ ├── store/ # Estado global con Zustand
|
||||
│ ├── alerts.ts # Lógica de alertas
|
||||
│ ├── types.ts # Tipos TypeScript
|
||||
│ └── utils.ts # Utilidades generales
|
||||
└── public/ # Archivos estáticos
|
||||
```
|
||||
|
||||
## 🎯 Funcionalidades Destacadas
|
||||
|
||||
### Estado Global con Zustand
|
||||
- Gestión reactiva del estado
|
||||
- Persistencia automática
|
||||
- Selectores optimizados
|
||||
- Actualizaciones en tiempo real
|
||||
|
||||
### Alertas Inteligentes
|
||||
```typescript
|
||||
// Tipos de alertas disponibles
|
||||
- PAYMENT_DUE: Pago próximo a vencer
|
||||
- BUDGET_WARNING: Límite de presupuesto alcanzado
|
||||
- CARD_CLOSING: Fecha de cierre de tarjeta
|
||||
- CARD_DUE: Vencimiento de tarjeta
|
||||
- SAVINGS_GOAL: Meta de ahorro alcanzada
|
||||
- UNUSUAL_SPENDING: Gasto inusual detectado
|
||||
```
|
||||
|
||||
### Categorización Automática
|
||||
- **Deudas Fijas**: vivienda, servicios, suscripciones
|
||||
- **Deudas Variables**: compras, comida, entretenimiento, salud
|
||||
- **Pagos con Cuotas**: seguimiento de instalaciones
|
||||
|
||||
## 📊 Métricas y Visualizaciones
|
||||
|
||||
- Gráficos de gastos por categoría
|
||||
- Tendencias de mes a mes
|
||||
- Progreso de metas de ahorro
|
||||
- Distribución de deuda total
|
||||
|
||||
## 🔐 Seguridad
|
||||
|
||||
- Validación de datos con TypeScript
|
||||
- Sanitización de inputs
|
||||
- Sin almacenamiento de datos sensibles
|
||||
- Ejecución completamente en el cliente
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
ISC
|
||||
|
||||
## 👨💻 Autor
|
||||
|
||||
Desarrollado por **renato97**
|
||||
|
||||
---
|
||||
|
||||
⭐ **¿Te gusta el proyecto?** ¡No olvides darle una estrella en Gitea!
|
||||
58
app/alerts/page.tsx
Normal file
58
app/alerts/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
||||
import { AlertPanel, useAlerts } from '@/components/alerts'
|
||||
import { useSidebar } from '@/app/providers'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { isOpen, toggle, close } = useSidebar()
|
||||
const { regenerateAlerts, dismissAll, unreadCount } = useAlerts()
|
||||
|
||||
const handleRegenerateAlerts = () => {
|
||||
regenerateAlerts()
|
||||
}
|
||||
|
||||
const handleDismissAll = () => {
|
||||
dismissAll()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950">
|
||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
||||
|
||||
<div className="lg:ml-64 min-h-screen flex flex-col">
|
||||
<Header onMenuClick={toggle} title="Alertas" />
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20 lg:pb-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
27
app/budget/page.tsx
Normal file
27
app/budget/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
||||
import { BudgetSection } from '@/components/budget'
|
||||
import { useSidebar } from '@/app/providers'
|
||||
import { useAlerts } from '@/components/alerts'
|
||||
|
||||
export default function BudgetPage() {
|
||||
const { isOpen, close, toggle } = useSidebar()
|
||||
const { unreadCount } = useAlerts()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-950">
|
||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
27
app/cards/page.tsx
Normal file
27
app/cards/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { Sidebar, Header, MobileNav } from '@/components/layout';
|
||||
import { CardSection } from '@/components/cards';
|
||||
import { useSidebar } from '@/app/providers';
|
||||
import { useAlerts } from '@/components/alerts';
|
||||
|
||||
export default function CardsPage() {
|
||||
const { isOpen, toggle, close } = useSidebar();
|
||||
const { unreadCount } = useAlerts();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950">
|
||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
32
app/debts/page.tsx
Normal file
32
app/debts/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
||||
import { DebtSection } from '@/components/debts'
|
||||
import { useSidebar } from '@/app/providers'
|
||||
import { useAlerts } from '@/components/alerts'
|
||||
|
||||
export default function DebtsPage() {
|
||||
const { isOpen, close, open } = useSidebar()
|
||||
const { unreadCount } = useAlerts()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950">
|
||||
{/* Sidebar */}
|
||||
<Sidebar isOpen={isOpen} onClose={close} unreadAlertsCount={unreadCount} />
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
332
app/globals.css
Normal file
332
app/globals.css
Normal file
@@ -0,0 +1,332 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Base Colors - Dark Theme (slate/emerald) */
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
/* Card Colors */
|
||||
--card: 217 33% 17%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
/* Popover Colors */
|
||||
--popover: 217 33% 17%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
/* Primary - Emerald */
|
||||
--primary: 160 84% 39%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 217 33% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
/* Border & Input */
|
||||
--border: 215 28% 25%;
|
||||
--input: 215 28% 25%;
|
||||
|
||||
/* Ring - Emerald */
|
||||
--ring: 160 84% 39%;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Input Styles */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background-color: hsl(var(--card));
|
||||
border-color: hsl(var(--border));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: hsl(160 84% 39% / 0.3);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Focus Ring */
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: hsl(var(--ring));
|
||||
ring-offset: 2px;
|
||||
ring-offset-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Firefox Scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) hsl(var(--background));
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, hsl(160 84% 39%) 0%, hsl(168 76% 42%) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: hsl(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--muted)) 25%,
|
||||
hsl(var(--muted-foreground) / 0.3) 50%,
|
||||
hsl(var(--muted)) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid hsl(var(--border));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-slight {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation Utility Classes */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-down {
|
||||
animation: fadeInDown 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slideInLeft 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-slight {
|
||||
animation: bounce-slight 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Stagger Animation Delays */
|
||||
.stagger-1 { animation-delay: 0.05s; }
|
||||
.stagger-2 { animation-delay: 0.1s; }
|
||||
.stagger-3 { animation-delay: 0.15s; }
|
||||
.stagger-4 { animation-delay: 0.2s; }
|
||||
.stagger-5 { animation-delay: 0.25s; }
|
||||
.stagger-6 { animation-delay: 0.3s; }
|
||||
.stagger-7 { animation-delay: 0.35s; }
|
||||
.stagger-8 { animation-delay: 0.4s; }
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
30
app/layout.tsx
Normal file
30
app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Finanzas Personales",
|
||||
description: "Gestiona tus finanzas personales de forma inteligente",
|
||||
keywords: ["finanzas", "presupuesto", "gastos", "ingresos", "ahorro"],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="es" className={inter.variable} suppressHydrationWarning>
|
||||
<body className={`${inter.className} antialiased min-h-screen bg-slate-950 text-slate-50`}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
134
app/page.tsx
Normal file
134
app/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sidebar, Header, MobileNav } from '@/components/layout'
|
||||
import { SummarySection, QuickActions, RecentActivity } from '@/components/dashboard'
|
||||
import { useSidebar } from '@/app/providers'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { AlertBanner, useAlerts } from '@/components/alerts'
|
||||
import { AddDebtModal } from '@/components/modals/AddDebtModal'
|
||||
import { AddCardModal } from '@/components/modals/AddCardModal'
|
||||
import { AddPaymentModal } from '@/components/modals/AddPaymentModal'
|
||||
|
||||
export default function Home() {
|
||||
// Sidebar control
|
||||
const sidebar = useSidebar()
|
||||
|
||||
// Datos del store
|
||||
const markAlertAsRead = useFinanzasStore((state) => state.markAlertAsRead)
|
||||
const deleteAlert = useFinanzasStore((state) => state.deleteAlert)
|
||||
|
||||
// Alertas
|
||||
const { unreadAlerts, unreadCount, regenerateAlerts } = useAlerts()
|
||||
|
||||
// Estados locales para modales
|
||||
const [isAddDebtModalOpen, setIsAddDebtModalOpen] = useState(false)
|
||||
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false)
|
||||
const [isAddPaymentModalOpen, setIsAddPaymentModalOpen] = useState(false)
|
||||
|
||||
// Efecto para regenerar alertas al cargar la página
|
||||
useEffect(() => {
|
||||
regenerateAlerts()
|
||||
}, [regenerateAlerts])
|
||||
|
||||
// Efecto para manejar resize de ventana
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
sidebar.open()
|
||||
} else {
|
||||
sidebar.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Estado inicial
|
||||
handleResize()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [sidebar])
|
||||
|
||||
// Handlers para modales
|
||||
const handleAddDebt = () => {
|
||||
setIsAddDebtModalOpen(true)
|
||||
}
|
||||
|
||||
const handleAddCard = () => {
|
||||
setIsAddCardModalOpen(true)
|
||||
}
|
||||
|
||||
const handleAddPayment = () => {
|
||||
setIsAddPaymentModalOpen(true)
|
||||
}
|
||||
|
||||
// Primeras 3 alertas no leídas
|
||||
const topAlerts = unreadAlerts.slice(0, 3)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-950">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
isOpen={sidebar.isOpen}
|
||||
onClose={sidebar.close}
|
||||
unreadAlertsCount={unreadCount}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col lg:ml-0">
|
||||
{/* Header */}
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<MobileNav unreadAlertsCount={unreadCount} />
|
||||
|
||||
{/* Modales */}
|
||||
<AddDebtModal
|
||||
isOpen={isAddDebtModalOpen}
|
||||
onClose={() => setIsAddDebtModalOpen(false)}
|
||||
/>
|
||||
|
||||
<AddCardModal
|
||||
isOpen={isAddCardModalOpen}
|
||||
onClose={() => setIsAddCardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<AddPaymentModal
|
||||
isOpen={isAddPaymentModalOpen}
|
||||
onClose={() => setIsAddPaymentModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
app/providers.tsx
Normal file
41
app/providers.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
|
||||
interface SidebarContextType {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
const toggleSidebar = () => setIsSidebarOpen((prev) => !prev);
|
||||
const closeSidebar = () => setIsSidebarOpen(false);
|
||||
const openSidebar = () => setIsSidebarOpen(true);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isOpen: isSidebarOpen,
|
||||
toggle: toggleSidebar,
|
||||
close: closeSidebar,
|
||||
open: openSidebar,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSidebar must be used within a Providers");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
32
components/alerts/AlertBadge.tsx
Normal file
32
components/alerts/AlertBadge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AlertBadgeProps {
|
||||
count: number
|
||||
variant?: 'default' | 'dot'
|
||||
}
|
||||
|
||||
export function AlertBadge({ count, variant = 'default' }: AlertBadgeProps) {
|
||||
if (count === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (variant === 'dot') {
|
||||
return (
|
||||
<span className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-red-500 animate-pulse" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center min-w-[20px] h-5 px-1.5',
|
||||
'rounded-full bg-red-500 text-white text-xs font-medium',
|
||||
'animate-pulse'
|
||||
)}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
112
components/alerts/AlertBanner.tsx
Normal file
112
components/alerts/AlertBanner.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { Alert } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertIcon } from './AlertIcon'
|
||||
|
||||
interface AlertBannerProps {
|
||||
alert: Alert
|
||||
onDismiss: () => void
|
||||
onMarkRead: () => void
|
||||
}
|
||||
|
||||
const severityStyles = {
|
||||
info: {
|
||||
bg: 'bg-blue-900/50',
|
||||
border: 'border-l-blue-500',
|
||||
icon: 'text-blue-400',
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-amber-900/50',
|
||||
border: 'border-l-amber-500',
|
||||
icon: 'text-amber-400',
|
||||
},
|
||||
danger: {
|
||||
bg: 'bg-red-900/50',
|
||||
border: 'border-l-red-500',
|
||||
icon: 'text-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
export function AlertBanner({ alert, onDismiss, onMarkRead }: AlertBannerProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const styles = severityStyles[alert.severity]
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
onDismiss()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleMarkRead = () => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
onMarkRead()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-r-lg border-l-4 p-4',
|
||||
'transition-all duration-300 ease-out',
|
||||
'animate-in slide-in-from-top-2',
|
||||
isExiting && 'animate-out slide-out-to-top-2 opacity-0',
|
||||
styles.bg,
|
||||
styles.border
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn('flex-shrink-0 mt-0.5', styles.icon)}>
|
||||
<AlertIcon type={alert.type} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-white text-sm">{alert.title}</h4>
|
||||
<p className="mt-1 text-sm text-gray-300">{alert.message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!alert.isRead && (
|
||||
<button
|
||||
onClick={handleMarkRead}
|
||||
className={cn(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
'text-gray-400 hover:text-white hover:bg-white/10',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/20'
|
||||
)}
|
||||
title="Marcar como leída"
|
||||
aria-label="Marcar como leída"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className={cn(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
'text-gray-400 hover:text-white hover:bg-white/10',
|
||||
'focus:outline-none focus:ring-2 focus:ring-white/20'
|
||||
)}
|
||||
title="Cerrar"
|
||||
aria-label="Cerrar alerta"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
components/alerts/AlertIcon.tsx
Normal file
25
components/alerts/AlertIcon.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { Info, AlertTriangle, AlertCircle, LucideIcon } from 'lucide-react'
|
||||
import { Alert } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AlertIconProps {
|
||||
type: Alert['type']
|
||||
className?: string
|
||||
}
|
||||
|
||||
const iconMap: Record<Alert['type'], LucideIcon> = {
|
||||
PAYMENT_DUE: AlertCircle,
|
||||
BUDGET_WARNING: AlertTriangle,
|
||||
CARD_CLOSING: Info,
|
||||
CARD_DUE: AlertCircle,
|
||||
SAVINGS_GOAL: Info,
|
||||
UNUSUAL_SPENDING: AlertTriangle,
|
||||
}
|
||||
|
||||
export function AlertIcon({ type, className }: AlertIconProps) {
|
||||
const Icon = iconMap[type]
|
||||
|
||||
return <Icon className={cn('h-5 w-5', className)} />
|
||||
}
|
||||
148
components/alerts/AlertItem.tsx
Normal file
148
components/alerts/AlertItem.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, Trash2 } from 'lucide-react'
|
||||
import { Alert } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertIcon } from './AlertIcon'
|
||||
|
||||
interface AlertItemProps {
|
||||
alert: Alert
|
||||
onMarkRead: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const severityStyles = {
|
||||
info: 'text-blue-400',
|
||||
warning: 'text-amber-400',
|
||||
danger: 'text-red-400',
|
||||
}
|
||||
|
||||
function getRelativeTime(date: string): string {
|
||||
const now = new Date()
|
||||
const alertDate = new Date(date)
|
||||
const diffMs = now.getTime() - alertDate.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMins < 1) {
|
||||
return 'ahora'
|
||||
}
|
||||
if (diffMins < 60) {
|
||||
return `hace ${diffMins} min`
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `hace ${diffHours} hora${diffHours > 1 ? 's' : ''}`
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return 'ayer'
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return `hace ${diffDays} días`
|
||||
}
|
||||
|
||||
return alertDate.toLocaleDateString('es-AR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
export function AlertItem({ alert, onMarkRead, onDelete }: AlertItemProps) {
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
|
||||
const handleMarkRead = () => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => {
|
||||
onMarkRead()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => {
|
||||
onDelete()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center gap-3 p-3 rounded-lg',
|
||||
'transition-all duration-200',
|
||||
'hover:bg-white/5',
|
||||
isExiting && 'opacity-0 -translate-x-4',
|
||||
!alert.isRead && 'bg-white/[0.02]'
|
||||
)}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
role="listitem"
|
||||
>
|
||||
{/* Unread indicator */}
|
||||
{!alert.isRead && (
|
||||
<span className="absolute left-1 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0',
|
||||
severityStyles[alert.severity],
|
||||
!alert.isRead && 'ml-3'
|
||||
)}
|
||||
>
|
||||
<AlertIcon type={alert.type} className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm truncate',
|
||||
alert.isRead ? 'text-gray-400' : 'text-white font-medium'
|
||||
)}
|
||||
>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{getRelativeTime(alert.date)}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 transition-opacity duration-200',
|
||||
showActions ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
{!alert.isRead && (
|
||||
<button
|
||||
onClick={handleMarkRead}
|
||||
className={cn(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
'text-gray-500 hover:text-green-400 hover:bg-green-400/10',
|
||||
'focus:outline-none focus:ring-2 focus:ring-green-400/20'
|
||||
)}
|
||||
title="Marcar como leída"
|
||||
aria-label="Marcar como leída"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={cn(
|
||||
'p-1.5 rounded-md transition-colors',
|
||||
'text-gray-500 hover:text-red-400 hover:bg-red-400/10',
|
||||
'focus:outline-none focus:ring-2 focus:ring-red-400/20'
|
||||
)}
|
||||
title="Eliminar"
|
||||
aria-label="Eliminar alerta"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
components/alerts/AlertPanel.tsx
Normal file
148
components/alerts/AlertPanel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Bell, CheckCheck, Trash2, Inbox } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { AlertItem } from './AlertItem'
|
||||
import { AlertBadge } from './AlertBadge'
|
||||
|
||||
type TabType = 'all' | 'unread'
|
||||
|
||||
export function AlertPanel() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all')
|
||||
|
||||
const alerts = useFinanzasStore((state) => state.alerts)
|
||||
const markAlertAsRead = useFinanzasStore((state) => state.markAlertAsRead)
|
||||
const deleteAlert = useFinanzasStore((state) => state.deleteAlert)
|
||||
const clearAllAlerts = useFinanzasStore((state) => state.clearAllAlerts)
|
||||
|
||||
const unreadAlerts = alerts.filter((alert) => !alert.isRead)
|
||||
const unreadCount = unreadAlerts.length
|
||||
|
||||
const displayedAlerts = activeTab === 'unread' ? unreadAlerts : alerts
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
unreadAlerts.forEach((alert) => {
|
||||
markAlertAsRead(alert.id)
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
clearAllAlerts()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md bg-gray-900 rounded-xl border border-gray-800 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Bell className="h-5 w-5 text-gray-400" />
|
||||
{unreadCount > 0 && <AlertBadge count={unreadCount} variant="dot" />}
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">Alertas</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-gray-500">({unreadCount})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className={cn(
|
||||
'p-2 rounded-md transition-colors',
|
||||
'text-gray-500 hover:text-green-400 hover:bg-green-400/10',
|
||||
'focus:outline-none focus:ring-2 focus:ring-green-400/20'
|
||||
)}
|
||||
title="Marcar todas como leídas"
|
||||
aria-label="Marcar todas como leídas"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className={cn(
|
||||
'p-2 rounded-md transition-colors',
|
||||
'text-gray-500 hover:text-red-400 hover:bg-red-400/10',
|
||||
'focus:outline-none focus:ring-2 focus:ring-red-400/20'
|
||||
)}
|
||||
title="Limpiar todas"
|
||||
aria-label="Limpiar todas las alertas"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="flex border-b border-gray-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 text-sm font-medium transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500/20',
|
||||
activeTab === 'all'
|
||||
? 'text-white border-b-2 border-blue-500'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
Todas
|
||||
<span className="ml-1.5 text-xs text-gray-600">({alerts.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('unread')}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 text-sm font-medium transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500/20',
|
||||
activeTab === 'unread'
|
||||
? 'text-white border-b-2 border-blue-500'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
No leídas
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-1.5 text-xs text-blue-400">({unreadCount})</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert List */}
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{displayedAlerts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-gray-800 flex items-center justify-center mb-3">
|
||||
<Inbox className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{activeTab === 'unread'
|
||||
? 'No tienes alertas sin leer'
|
||||
: 'No tienes alertas'}
|
||||
</p>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
Las alertas aparecerán cuando haya pagos próximos o eventos importantes
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800/50" role="list">
|
||||
{displayedAlerts.map((alert) => (
|
||||
<AlertItem
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onMarkRead={() => markAlertAsRead(alert.id)}
|
||||
onDelete={() => deleteAlert(alert.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
components/alerts/index.ts
Normal file
6
components/alerts/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AlertBanner } from './AlertBanner'
|
||||
export { AlertItem } from './AlertItem'
|
||||
export { AlertPanel } from './AlertPanel'
|
||||
export { AlertBadge } from './AlertBadge'
|
||||
export { AlertIcon } from './AlertIcon'
|
||||
export { useAlerts } from './useAlerts'
|
||||
68
components/alerts/useAlerts.ts
Normal file
68
components/alerts/useAlerts.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { generateAlerts, GenerateAlertsParams } from '@/lib/alerts'
|
||||
|
||||
export function useAlerts() {
|
||||
const alerts = useFinanzasStore((state) => state.alerts)
|
||||
const addAlert = useFinanzasStore((state) => state.addAlert)
|
||||
const clearAllAlerts = useFinanzasStore((state) => state.clearAllAlerts)
|
||||
|
||||
const fixedDebts = useFinanzasStore((state) => state.fixedDebts)
|
||||
const variableDebts = useFinanzasStore((state) => state.variableDebts)
|
||||
const creditCards = useFinanzasStore((state) => state.creditCards)
|
||||
const monthlyBudgets = useFinanzasStore((state) => state.monthlyBudgets)
|
||||
const currentMonth = useFinanzasStore((state) => state.currentMonth)
|
||||
const currentYear = useFinanzasStore((state) => state.currentYear)
|
||||
|
||||
const unreadAlerts = useMemo(
|
||||
() => alerts.filter((alert) => !alert.isRead),
|
||||
[alerts]
|
||||
)
|
||||
|
||||
const unreadCount = unreadAlerts.length
|
||||
|
||||
const regenerateAlerts = useCallback(() => {
|
||||
const params: GenerateAlertsParams = {
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
creditCards,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear,
|
||||
}
|
||||
|
||||
const newAlerts = generateAlerts(params)
|
||||
|
||||
// Clear existing alerts and add new ones
|
||||
clearAllAlerts()
|
||||
|
||||
newAlerts.forEach((alertDraft) => {
|
||||
addAlert({ ...alertDraft, isRead: false })
|
||||
})
|
||||
|
||||
return newAlerts.length
|
||||
}, [
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
creditCards,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear,
|
||||
clearAllAlerts,
|
||||
addAlert,
|
||||
])
|
||||
|
||||
const dismissAll = useCallback(() => {
|
||||
clearAllAlerts()
|
||||
}, [clearAllAlerts])
|
||||
|
||||
return {
|
||||
alerts,
|
||||
unreadCount,
|
||||
unreadAlerts,
|
||||
regenerateAlerts,
|
||||
dismissAll,
|
||||
}
|
||||
}
|
||||
50
components/budget/BudgetCard.tsx
Normal file
50
components/budget/BudgetCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface BudgetCardProps {
|
||||
label: string
|
||||
amount: number
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function BudgetCard({ label, amount, trend = 'neutral', color }: BudgetCardProps) {
|
||||
const getTrendIcon = () => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||
case 'down':
|
||||
return <TrendingDown className="w-4 h-4 text-red-400" />
|
||||
default:
|
||||
return <Minus className="w-4 h-4 text-slate-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getTrendText = () => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <span className="text-emerald-400 text-xs">Positivo</span>
|
||||
case 'down':
|
||||
return <span className="text-red-400 text-xs">Negativo</span>
|
||||
default:
|
||||
return <span className="text-slate-500 text-xs">Neutral</span>
|
||||
}
|
||||
}
|
||||
|
||||
const textColor = color || 'text-white'
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-slate-400 text-sm">{label}</p>
|
||||
{getTrendIcon()}
|
||||
</div>
|
||||
<p className={cn('text-2xl font-mono font-semibold mt-2', textColor)}>
|
||||
{formatCurrency(amount)}
|
||||
</p>
|
||||
<div className="mt-2">{getTrendText()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
components/budget/BudgetForm.tsx
Normal file
198
components/budget/BudgetForm.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { MonthlyBudget } from '@/lib/types'
|
||||
import { cn, getMonthName } from '@/lib/utils'
|
||||
|
||||
interface BudgetFormProps {
|
||||
onSubmit: (budget: MonthlyBudget) => void
|
||||
onCancel: () => void
|
||||
initialData?: MonthlyBudget
|
||||
}
|
||||
|
||||
const months = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: getMonthName(i + 1),
|
||||
}))
|
||||
|
||||
export function BudgetForm({ onSubmit, onCancel, initialData }: BudgetFormProps) {
|
||||
const now = new Date()
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
totalIncome: initialData?.totalIncome || 0,
|
||||
savingsGoal: initialData?.savingsGoal || 0,
|
||||
month: initialData?.month || now.getMonth() + 1,
|
||||
year: initialData?.year || now.getFullYear(),
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (formData.totalIncome <= 0) {
|
||||
newErrors.totalIncome = 'Los ingresos deben ser mayores a 0'
|
||||
}
|
||||
|
||||
if (formData.savingsGoal >= formData.totalIncome) {
|
||||
newErrors.savingsGoal = 'La meta de ahorro debe ser menor que los ingresos'
|
||||
}
|
||||
|
||||
if (formData.month < 1 || formData.month > 12) {
|
||||
newErrors.month = 'El mes debe estar entre 1 y 12'
|
||||
}
|
||||
|
||||
if (formData.year < 2000 || formData.year > 2100) {
|
||||
newErrors.year = 'El año no es válido'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (validate()) {
|
||||
onSubmit({
|
||||
month: formData.month,
|
||||
year: formData.year,
|
||||
totalIncome: formData.totalIncome,
|
||||
savingsGoal: formData.savingsGoal,
|
||||
fixedExpenses: initialData?.fixedExpenses || 0,
|
||||
variableExpenses: initialData?.variableExpenses || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof typeof formData>(
|
||||
field: K,
|
||||
value: typeof formData[K]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[field]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="month" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Mes <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="month"
|
||||
value={formData.month}
|
||||
onChange={(e) => updateField('month', parseInt(e.target.value))}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.month ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
>
|
||||
{months.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.month && <p className="mt-1 text-sm text-red-400">{errors.month}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="year" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Año <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="year"
|
||||
min="2000"
|
||||
max="2100"
|
||||
value={formData.year}
|
||||
onChange={(e) => updateField('year', parseInt(e.target.value) || now.getFullYear())}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.year ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
/>
|
||||
{errors.year && <p className="mt-1 text-sm text-red-400">{errors.year}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="totalIncome" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Ingresos totales <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="totalIncome"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.totalIncome || ''}
|
||||
onChange={(e) => updateField('totalIncome', parseFloat(e.target.value) || 0)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.totalIncome ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.totalIncome && <p className="mt-1 text-sm text-red-400">{errors.totalIncome}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="savingsGoal" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Meta de ahorro <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="savingsGoal"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.savingsGoal || ''}
|
||||
onChange={(e) => updateField('savingsGoal', parseFloat(e.target.value) || 0)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.savingsGoal ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.savingsGoal && <p className="mt-1 text-sm text-red-400">{errors.savingsGoal}</p>}
|
||||
{formData.totalIncome > 0 && (
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Disponible para gastos: {((formData.totalIncome - formData.savingsGoal) / formData.totalIncome * 100).toFixed(0)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 bg-slate-700 text-slate-200 rounded-lg font-medium',
|
||||
'hover:bg-slate-600 transition-colors'
|
||||
)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
|
||||
'hover:bg-blue-500 transition-colors'
|
||||
)}
|
||||
>
|
||||
{initialData ? 'Guardar cambios' : 'Crear presupuesto'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
44
components/budget/BudgetProgress.tsx
Normal file
44
components/budget/BudgetProgress.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface BudgetProgressProps {
|
||||
current: number
|
||||
max: number
|
||||
label: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function BudgetProgress({ current, max, label, color }: BudgetProgressProps) {
|
||||
const percentage = max > 0 ? Math.min((current / max) * 100, 100) : 0
|
||||
|
||||
const getColorClass = () => {
|
||||
if (color) return color
|
||||
if (percentage < 70) return 'bg-emerald-500'
|
||||
if (percentage < 90) return 'bg-amber-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-300">{label}</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{formatCurrency(current)} <span className="text-slate-600">/ {formatCurrency(max)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500 ease-out', getColorClass())}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-slate-500">{percentage.toFixed(0)}% usado</span>
|
||||
{percentage >= 100 && (
|
||||
<span className="text-xs text-red-400 font-medium">Límite alcanzado</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
components/budget/BudgetRing.tsx
Normal file
83
components/budget/BudgetRing.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface BudgetRingProps {
|
||||
spent: number
|
||||
total: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export function BudgetRing({ spent, total, label }: BudgetRingProps) {
|
||||
const percentage = total > 0 ? Math.min((spent / total) * 100, 100) : 0
|
||||
const remaining = Math.max(total - spent, 0)
|
||||
|
||||
const getColor = () => {
|
||||
if (percentage < 70) return { stroke: '#10b981', bg: 'text-emerald-400' }
|
||||
if (percentage < 90) return { stroke: '#f59e0b', bg: 'text-amber-400' }
|
||||
return { stroke: '#ef4444', bg: 'text-red-400' }
|
||||
}
|
||||
|
||||
const colors = getColor()
|
||||
|
||||
const radius = 80
|
||||
const strokeWidth = 12
|
||||
const normalizedRadius = radius - strokeWidth / 2
|
||||
const circumference = normalizedRadius * 2 * Math.PI
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative">
|
||||
<svg
|
||||
width={radius * 2}
|
||||
height={radius * 2}
|
||||
className="transform -rotate-90"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
stroke="#334155"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
r={normalizedRadius}
|
||||
cx={radius}
|
||||
cy={radius}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
stroke={colors.stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
r={normalizedRadius}
|
||||
cx={radius}
|
||||
cy={radius}
|
||||
style={{
|
||||
strokeDasharray: `${circumference} ${circumference}`,
|
||||
strokeDashoffset,
|
||||
transition: 'stroke-dashoffset 0.5s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
{/* Center content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={cn('text-3xl font-bold', colors.bg)}>
|
||||
{percentage.toFixed(0)}%
|
||||
</span>
|
||||
<span className="text-slate-400 text-sm mt-1">usado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats below */}
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-slate-400 text-sm">{label}</p>
|
||||
<p className="text-lg font-semibold text-white mt-1">
|
||||
{formatCurrency(spent)} <span className="text-slate-500">/ {formatCurrency(total)}</span>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{formatCurrency(remaining)} disponible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
269
components/budget/BudgetSection.tsx
Normal file
269
components/budget/BudgetSection.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { MonthlyBudget } from '@/lib/types'
|
||||
import { BudgetForm } from './BudgetForm'
|
||||
import { BudgetRing } from './BudgetRing'
|
||||
import { BudgetProgress } from './BudgetProgress'
|
||||
import { BudgetCard } from './BudgetCard'
|
||||
import { cn, formatCurrency, getMonthName, calculateTotalFixedDebts, calculateTotalVariableDebts, calculateCardPayments } from '@/lib/utils'
|
||||
import { Plus, Wallet, Edit3, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react'
|
||||
|
||||
export function BudgetSection() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const {
|
||||
monthlyBudgets,
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
cardPayments,
|
||||
currentMonth,
|
||||
currentYear,
|
||||
setMonthlyBudget,
|
||||
} = useFinanzasStore()
|
||||
|
||||
const currentBudget = useMemo(() => {
|
||||
return monthlyBudgets.find(
|
||||
(b) => b.month === currentMonth && b.year === currentYear
|
||||
)
|
||||
}, [monthlyBudgets, currentMonth, currentYear])
|
||||
|
||||
const fixedExpenses = useMemo(() => calculateTotalFixedDebts(fixedDebts), [fixedDebts])
|
||||
const variableExpenses = useMemo(() => calculateTotalVariableDebts(variableDebts), [variableDebts])
|
||||
const cardExpenses = useMemo(() => calculateCardPayments(cardPayments), [cardPayments])
|
||||
|
||||
const totalSpent = fixedExpenses + variableExpenses + cardExpenses
|
||||
const totalIncome = currentBudget?.totalIncome || 0
|
||||
const savingsGoal = currentBudget?.savingsGoal || 0
|
||||
const availableForExpenses = totalIncome - savingsGoal
|
||||
const remaining = availableForExpenses - totalSpent
|
||||
|
||||
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate()
|
||||
const currentDay = new Date().getDate()
|
||||
const daysRemaining = daysInMonth - currentDay
|
||||
const dailySpendRate = currentDay > 0 ? totalSpent / currentDay : 0
|
||||
const projectedEndOfMonth = totalSpent + dailySpendRate * daysRemaining
|
||||
|
||||
const handleCreateBudget = () => {
|
||||
setIsEditing(false)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEditBudget = () => {
|
||||
setIsEditing(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleSubmit = (budget: MonthlyBudget) => {
|
||||
setMonthlyBudget(budget)
|
||||
handleCloseModal()
|
||||
}
|
||||
|
||||
if (!currentBudget) {
|
||||
return (
|
||||
<div className="bg-slate-900 min-h-screen p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Presupuesto Mensual</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
{getMonthName(currentMonth)} {currentYear}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-16 bg-slate-800/50 border border-slate-700/50 rounded-lg">
|
||||
<Wallet className="w-12 h-12 text-slate-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-slate-300">
|
||||
No hay presupuesto para este mes
|
||||
</h3>
|
||||
<p className="text-slate-500 mt-2 mb-6">
|
||||
Crea un presupuesto para comenzar a gestionar tus finanzas
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCreateBudget}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
|
||||
'hover:bg-blue-500 transition-colors'
|
||||
)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Crear presupuesto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
Nuevo presupuesto
|
||||
</h2>
|
||||
<BudgetForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 min-h-screen p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Presupuesto Mensual</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
{getMonthName(currentMonth)} {currentYear}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEditBudget}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 bg-slate-700 text-white rounded-lg font-medium',
|
||||
'hover:bg-slate-600 transition-colors'
|
||||
)}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<BudgetCard
|
||||
label="Ingresos totales"
|
||||
amount={totalIncome}
|
||||
trend="up"
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
<BudgetCard
|
||||
label="Meta de ahorro"
|
||||
amount={savingsGoal}
|
||||
trend="neutral"
|
||||
color="text-blue-400"
|
||||
/>
|
||||
<BudgetCard
|
||||
label="Gastado"
|
||||
amount={totalSpent}
|
||||
trend={totalSpent > availableForExpenses ? 'down' : 'neutral'}
|
||||
color={totalSpent > availableForExpenses ? 'text-red-400' : 'text-amber-400'}
|
||||
/>
|
||||
<BudgetCard
|
||||
label="Disponible"
|
||||
amount={remaining}
|
||||
trend={remaining > 0 ? 'up' : 'down'}
|
||||
color={remaining > 0 ? 'text-emerald-400' : 'text-red-400'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Budget Ring and Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* Ring */}
|
||||
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6 flex items-center justify-center">
|
||||
<BudgetRing
|
||||
spent={totalSpent}
|
||||
total={availableForExpenses}
|
||||
label="Presupuesto mensual"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Breakdown */}
|
||||
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Desglose de gastos</h3>
|
||||
<div className="space-y-4">
|
||||
<BudgetProgress
|
||||
current={fixedExpenses}
|
||||
max={availableForExpenses}
|
||||
label="Deudas fijas pendientes"
|
||||
/>
|
||||
<BudgetProgress
|
||||
current={variableExpenses}
|
||||
max={availableForExpenses}
|
||||
label="Deudas variables pendientes"
|
||||
/>
|
||||
<BudgetProgress
|
||||
current={cardExpenses}
|
||||
max={availableForExpenses}
|
||||
label="Pagos de tarjetas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projection */}
|
||||
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
projectedEndOfMonth > availableForExpenses ? 'bg-red-500/10' : 'bg-emerald-500/10'
|
||||
)}>
|
||||
{projectedEndOfMonth > availableForExpenses ? (
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
) : (
|
||||
<TrendingUp className="w-5 h-5 text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Proyección</h3>
|
||||
<p className="text-slate-400 mt-1">
|
||||
A tu ritmo actual de gasto ({formatCurrency(dailySpendRate)}/día),
|
||||
{projectedEndOfMonth > availableForExpenses ? (
|
||||
<span className="text-red-400">
|
||||
{' '}terminarás el mes con un déficit de {formatCurrency(projectedEndOfMonth - availableForExpenses)}.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-400">
|
||||
{' '}terminarás el mes con un superávit de {formatCurrency(availableForExpenses - projectedEndOfMonth)}.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm mt-2">
|
||||
Quedan {daysRemaining} días en el mes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
{isEditing ? 'Editar presupuesto' : 'Nuevo presupuesto'}
|
||||
</h2>
|
||||
<BudgetForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCloseModal}
|
||||
initialData={isEditing ? currentBudget : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
components/budget/index.ts
Normal file
5
components/budget/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { BudgetForm } from './BudgetForm';
|
||||
export { BudgetRing } from './BudgetRing';
|
||||
export { BudgetProgress } from './BudgetProgress';
|
||||
export { BudgetCard } from './BudgetCard';
|
||||
export { BudgetSection } from './BudgetSection';
|
||||
255
components/cards/CardPaymentForm.tsx
Normal file
255
components/cards/CardPaymentForm.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, Check } from 'lucide-react'
|
||||
|
||||
interface CardPaymentFormData {
|
||||
description: string
|
||||
amount: number
|
||||
date: string
|
||||
installments?: {
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
interface CardPaymentFormProps {
|
||||
cardId: string
|
||||
onSubmit: (data: CardPaymentFormData) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function CardPaymentForm({ cardId, onSubmit, onCancel }: CardPaymentFormProps) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const [formData, setFormData] = useState<CardPaymentFormData>({
|
||||
description: '',
|
||||
amount: 0,
|
||||
date: today,
|
||||
installments: undefined,
|
||||
})
|
||||
|
||||
const [hasInstallments, setHasInstallments] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.description.trim()) {
|
||||
newErrors.description = 'La descripción es requerida'
|
||||
}
|
||||
|
||||
if (formData.amount <= 0) {
|
||||
newErrors.amount = 'El monto debe ser mayor a 0'
|
||||
}
|
||||
|
||||
if (!formData.date) {
|
||||
newErrors.date = 'La fecha es requerida'
|
||||
}
|
||||
|
||||
if (hasInstallments && formData.installments) {
|
||||
if (formData.installments.current < 1) {
|
||||
newErrors.installmentCurrent = 'La cuota actual debe ser al menos 1'
|
||||
}
|
||||
if (formData.installments.total < 2) {
|
||||
newErrors.installmentTotal = 'El total de cuotas debe ser al menos 2'
|
||||
}
|
||||
if (formData.installments.current > formData.installments.total) {
|
||||
newErrors.installments = 'La cuota actual no puede ser mayor al total'
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (validateForm()) {
|
||||
onSubmit({
|
||||
...formData,
|
||||
installments: hasInstallments ? formData.installments : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateField = (field: keyof CardPaymentFormData, value: string | number) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[field]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateInstallmentField = (field: 'current' | 'total', value: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
installments: {
|
||||
current: field === 'current' ? value : (prev.installments?.current ?? 1),
|
||||
total: field === 'total' ? value : (prev.installments?.total ?? 1),
|
||||
},
|
||||
}))
|
||||
// Clear related errors
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[`installment${field.charAt(0).toUpperCase() + field.slice(1)}`]
|
||||
delete newErrors.installments
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl bg-slate-800 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">Registrar Pago</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Descripción
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="Ej: Supermercado Coto"
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Monto
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={formData.amount || ''}
|
||||
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.amount && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.amount}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Fecha
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => updateField('date', e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.date && <p className="mt-1 text-sm text-rose-400">{errors.date}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installments Toggle */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-700/50 p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="hasInstallments"
|
||||
checked={hasInstallments}
|
||||
onChange={(e) => {
|
||||
setHasInstallments(e.target.checked)
|
||||
if (!e.target.checked) {
|
||||
setFormData((prev) => ({ ...prev, installments: undefined }))
|
||||
} else {
|
||||
setFormData((prev) => ({ ...prev, installments: { current: 1, total: 1 } }))
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-slate-500 bg-slate-600 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<label htmlFor="hasInstallments" className="text-sm font-medium text-slate-300">
|
||||
Este pago es en cuotas
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Installments Fields */}
|
||||
{hasInstallments && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Cuota actual
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={formData.installments?.current || ''}
|
||||
onChange={(e) =>
|
||||
updateInstallmentField('current', parseInt(e.target.value) || 1)
|
||||
}
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.installmentCurrent && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.installmentCurrent}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Total de cuotas
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
value={formData.installments?.total || ''}
|
||||
onChange={(e) =>
|
||||
updateInstallmentField('total', parseInt(e.target.value) || 2)
|
||||
}
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.installmentTotal && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.installmentTotal}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errors.installments && (
|
||||
<p className="text-sm text-rose-400">{errors.installments}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-700"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Registrar pago
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
325
components/cards/CardSection.tsx
Normal file
325
components/cards/CardSection.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { CreditCardWidget } from './CreditCardWidget'
|
||||
import { CreditCardForm } from './CreditCardForm'
|
||||
import { CardPaymentForm } from './CardPaymentForm'
|
||||
import { MiniCard } from './MiniCard'
|
||||
import { CreditCard, CardPayment } from '@/lib/types'
|
||||
import { formatCurrency, formatShortDate, getMonthName } from '@/lib/utils'
|
||||
import { Plus, CreditCard as CreditCardIcon, Receipt, Trash2 } from 'lucide-react'
|
||||
|
||||
export function CardSection() {
|
||||
const {
|
||||
creditCards,
|
||||
cardPayments,
|
||||
currentMonth,
|
||||
currentYear,
|
||||
addCreditCard,
|
||||
updateCreditCard,
|
||||
deleteCreditCard,
|
||||
addCardPayment,
|
||||
deleteCardPayment,
|
||||
} = useFinanzasStore()
|
||||
|
||||
const [showCardForm, setShowCardForm] = useState(false)
|
||||
const [editingCard, setEditingCard] = useState<CreditCard | null>(null)
|
||||
const [selectedCardId, setSelectedCardId] = useState<string>('')
|
||||
const [showPaymentForm, setShowPaymentForm] = useState(false)
|
||||
|
||||
// Filter payments for current month
|
||||
const currentMonthPayments = useMemo(() => {
|
||||
return cardPayments.filter((payment) => {
|
||||
const paymentDate = new Date(payment.date)
|
||||
return (
|
||||
paymentDate.getMonth() + 1 === currentMonth &&
|
||||
paymentDate.getFullYear() === currentYear
|
||||
)
|
||||
})
|
||||
}, [cardPayments, currentMonth, currentYear])
|
||||
|
||||
const handleCardSubmit = (data: Omit<CreditCard, 'id'>) => {
|
||||
if (editingCard) {
|
||||
updateCreditCard(editingCard.id, data)
|
||||
setEditingCard(null)
|
||||
} else {
|
||||
addCreditCard(data)
|
||||
}
|
||||
setShowCardForm(false)
|
||||
}
|
||||
|
||||
const handleEditCard = (card: CreditCard) => {
|
||||
setEditingCard(card)
|
||||
setShowCardForm(true)
|
||||
}
|
||||
|
||||
const handleDeleteCard = (cardId: string) => {
|
||||
if (window.confirm('¿Estás seguro de que deseas eliminar esta tarjeta?')) {
|
||||
deleteCreditCard(cardId)
|
||||
if (selectedCardId === cardId) {
|
||||
setSelectedCardId('')
|
||||
setShowPaymentForm(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentSubmit = (data: {
|
||||
description: string
|
||||
amount: number
|
||||
date: string
|
||||
installments?: { current: number; total: number }
|
||||
}) => {
|
||||
addCardPayment({
|
||||
cardId: selectedCardId,
|
||||
...data,
|
||||
})
|
||||
setShowPaymentForm(false)
|
||||
}
|
||||
|
||||
const handleDeletePayment = (paymentId: string) => {
|
||||
if (window.confirm('¿Estás seguro de que deseas eliminar este pago?')) {
|
||||
deleteCardPayment(paymentId)
|
||||
}
|
||||
}
|
||||
|
||||
const getCardById = (cardId: string): CreditCard | undefined => {
|
||||
return creditCards.find((card) => card.id === cardId)
|
||||
}
|
||||
|
||||
const getCardTotalPayments = (cardId: string): number => {
|
||||
return currentMonthPayments
|
||||
.filter((payment) => payment.cardId === cardId)
|
||||
.reduce((total, payment) => total + payment.amount, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-500/20">
|
||||
<CreditCardIcon className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">Tarjetas de Crédito</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
{getMonthName(currentMonth)} {currentYear}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCard(null)
|
||||
setShowCardForm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Agregar tarjeta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
{creditCards.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-600 bg-slate-800/50 p-12 text-center">
|
||||
<CreditCardIcon className="mx-auto h-12 w-12 text-slate-500" />
|
||||
<h3 className="mt-4 text-lg font-medium text-slate-300">
|
||||
No tienes tarjetas registradas
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Agrega tu primera tarjeta para comenzar a gestionar tus pagos
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCard(null)
|
||||
setShowCardForm(true)
|
||||
}}
|
||||
className="mt-4 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Agregar tarjeta
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{creditCards.map((card) => (
|
||||
<CreditCardWidget
|
||||
key={card.id}
|
||||
card={card}
|
||||
onEdit={() => handleEditCard(card)}
|
||||
onDelete={() => handleDeleteCard(card.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Form Modal */}
|
||||
{showCardForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg">
|
||||
<CreditCardForm
|
||||
initialData={editingCard ?? undefined}
|
||||
onSubmit={handleCardSubmit}
|
||||
onCancel={() => {
|
||||
setShowCardForm(false)
|
||||
setEditingCard(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Section */}
|
||||
{creditCards.length > 0 && (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Register Payment */}
|
||||
<div className="rounded-xl bg-slate-800 p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20">
|
||||
<Receipt className="h-4 w-4 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Registrar Pago</h3>
|
||||
</div>
|
||||
|
||||
{/* Card Selector */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-300">
|
||||
Seleccionar tarjeta
|
||||
</label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{creditCards.map((card) => (
|
||||
<MiniCard
|
||||
key={card.id}
|
||||
card={card}
|
||||
selected={selectedCardId === card.id}
|
||||
onClick={() => {
|
||||
setSelectedCardId(card.id)
|
||||
setShowPaymentForm(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Form */}
|
||||
{showPaymentForm && selectedCardId && (
|
||||
<CardPaymentForm
|
||||
cardId={selectedCardId}
|
||||
onSubmit={handlePaymentSubmit}
|
||||
onCancel={() => {
|
||||
setShowPaymentForm(false)
|
||||
setSelectedCardId('')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Payments */}
|
||||
<div className="rounded-xl bg-slate-800 p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/20">
|
||||
<Receipt className="h-4 w-4 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Pagos del Mes</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Total: {formatCurrency(
|
||||
currentMonthPayments.reduce((sum, p) => sum + p.amount, 0)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentMonthPayments.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-600 p-8 text-center">
|
||||
<p className="text-sm text-slate-400">
|
||||
No hay pagos registrados este mes
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{currentMonthPayments
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.map((payment) => {
|
||||
const card = getCardById(payment.cardId)
|
||||
return (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-700/50 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{card && (
|
||||
<div
|
||||
className="h-8 w-8 shrink-0 rounded-md"
|
||||
style={{ backgroundColor: card.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-white">
|
||||
{payment.description}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{card?.name} • {formatShortDate(payment.date)}
|
||||
{payment.installments && (
|
||||
<span className="ml-2 text-amber-400">
|
||||
Cuota {payment.installments.current}/{payment.installments.total}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-white">
|
||||
{formatCurrency(payment.amount)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeletePayment(payment.id)}
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-rose-500/20 hover:text-rose-400"
|
||||
aria-label="Eliminar pago"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary by Card */}
|
||||
{currentMonthPayments.length > 0 && (
|
||||
<div className="mt-6 border-t border-slate-700 pt-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-slate-300">
|
||||
Resumen por tarjeta
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{creditCards.map((card) => {
|
||||
const total = getCardTotalPayments(card.id)
|
||||
if (total === 0) return null
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: card.color }}
|
||||
/>
|
||||
<span className="text-slate-300">{card.name}</span>
|
||||
</div>
|
||||
<span className="font-medium text-white">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
components/cards/CreditCardForm.tsx
Normal file
242
components/cards/CreditCardForm.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CreditCard } from '@/lib/types'
|
||||
import { X, Check } from 'lucide-react'
|
||||
|
||||
interface CreditCardFormProps {
|
||||
initialData?: Partial<CreditCard>
|
||||
onSubmit: (data: Omit<CreditCard, 'id'>) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = '#6366f1'
|
||||
|
||||
export function CreditCardForm({ initialData, onSubmit, onCancel }: CreditCardFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name ?? '',
|
||||
lastFourDigits: initialData?.lastFourDigits ?? '',
|
||||
closingDay: initialData?.closingDay ?? 1,
|
||||
dueDay: initialData?.dueDay ?? 10,
|
||||
currentBalance: initialData?.currentBalance ?? 0,
|
||||
creditLimit: initialData?.creditLimit ?? 0,
|
||||
color: initialData?.color ?? DEFAULT_COLOR,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'El nombre es requerido'
|
||||
}
|
||||
|
||||
if (!formData.lastFourDigits.trim()) {
|
||||
newErrors.lastFourDigits = 'Los últimos 4 dígitos son requeridos'
|
||||
} else if (!/^\d{4}$/.test(formData.lastFourDigits)) {
|
||||
newErrors.lastFourDigits = 'Debe ser exactamente 4 dígitos numéricos'
|
||||
}
|
||||
|
||||
if (formData.closingDay < 1 || formData.closingDay > 31) {
|
||||
newErrors.closingDay = 'El día debe estar entre 1 y 31'
|
||||
}
|
||||
|
||||
if (formData.dueDay < 1 || formData.dueDay > 31) {
|
||||
newErrors.dueDay = 'El día debe estar entre 1 y 31'
|
||||
}
|
||||
|
||||
if (formData.creditLimit <= 0) {
|
||||
newErrors.creditLimit = 'El límite de crédito debe ser mayor a 0'
|
||||
}
|
||||
|
||||
if (formData.currentBalance < 0) {
|
||||
newErrors.currentBalance = 'El balance no puede ser negativo'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (validateForm()) {
|
||||
onSubmit(formData)
|
||||
}
|
||||
}
|
||||
|
||||
const updateField = (field: keyof typeof formData, value: string | number) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[field]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleLastFourDigitsChange = (value: string) => {
|
||||
// Only allow digits and max 4 characters
|
||||
const digitsOnly = value.replace(/\D/g, '').slice(0, 4)
|
||||
updateField('lastFourDigits', digitsOnly)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-xl bg-slate-800 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{initialData ? 'Editar Tarjeta' : 'Nueva Tarjeta'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-full p-1 text-slate-400 transition-colors hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Card Name */}
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Nombre de la tarjeta
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder="Ej: Visa Banco Galicia"
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-rose-400">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Last 4 Digits */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Últimos 4 dígitos
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formData.lastFourDigits}
|
||||
onChange={(e) => handleLastFourDigitsChange(e.target.value)}
|
||||
placeholder="1234"
|
||||
maxLength={4}
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.lastFourDigits && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.lastFourDigits}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">Color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => updateField('color', e.target.value)}
|
||||
className="h-10 w-20 cursor-pointer rounded-lg border border-slate-600 bg-slate-700"
|
||||
/>
|
||||
<span className="text-sm text-slate-400">{formData.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Closing Day */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Día de cierre
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={formData.closingDay}
|
||||
onChange={(e) => updateField('closingDay', parseInt(e.target.value) || 1)}
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.closingDay && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.closingDay}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Due Day */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Día de vencimiento
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={formData.dueDay}
|
||||
onChange={(e) => updateField('dueDay', parseInt(e.target.value) || 1)}
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.dueDay && <p className="mt-1 text-sm text-rose-400">{errors.dueDay}</p>}
|
||||
</div>
|
||||
|
||||
{/* Credit Limit */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Límite de crédito
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={formData.creditLimit || ''}
|
||||
onChange={(e) => updateField('creditLimit', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.creditLimit && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.creditLimit}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Balance */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-slate-300">
|
||||
Balance actual
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={formData.currentBalance || ''}
|
||||
onChange={(e) => updateField('currentBalance', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-lg border border-slate-600 bg-slate-700 px-4 py-2 text-white placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||
/>
|
||||
{errors.currentBalance && (
|
||||
<p className="mt-1 text-sm text-rose-400">{errors.currentBalance}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-300 transition-colors hover:bg-slate-700"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{initialData ? 'Guardar cambios' : 'Crear tarjeta'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
123
components/cards/CreditCardWidget.tsx
Normal file
123
components/cards/CreditCardWidget.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { CreditCard } from '@/lib/types'
|
||||
import { formatCurrency, getCardUtilization, getDaysUntil, calculateNextClosingDate, calculateNextDueDate } from '@/lib/utils'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
|
||||
interface CreditCardWidgetProps {
|
||||
card: CreditCard
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function CreditCardWidget({ card, onEdit, onDelete }: CreditCardWidgetProps) {
|
||||
const utilization = getCardUtilization(card.currentBalance, card.creditLimit)
|
||||
const nextClosing = calculateNextClosingDate(card.closingDay)
|
||||
const nextDue = calculateNextDueDate(card.dueDay)
|
||||
const daysUntilClosing = getDaysUntil(nextClosing)
|
||||
const daysUntilDue = getDaysUntil(nextDue)
|
||||
|
||||
const getUtilizationColor = (util: number): string => {
|
||||
if (util < 30) return 'bg-emerald-500'
|
||||
if (util < 70) return 'bg-amber-500'
|
||||
return 'bg-rose-500'
|
||||
}
|
||||
|
||||
const getUtilizationTextColor = (util: number): string => {
|
||||
if (util < 30) return 'text-emerald-400'
|
||||
if (util < 70) return 'text-amber-400'
|
||||
return 'text-rose-400'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl p-6 text-white shadow-lg transition-transform hover:scale-[1.02]"
|
||||
style={{
|
||||
aspectRatio: '1.586',
|
||||
background: `linear-gradient(135deg, ${card.color} 0%, ${adjustColor(card.color, -30)} 100%)`,
|
||||
}}
|
||||
>
|
||||
{/* Decorative circles */}
|
||||
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-white/10" />
|
||||
<div className="absolute -bottom-12 -left-12 h-40 w-40 rounded-full bg-white/5" />
|
||||
|
||||
{/* Header with card name and actions */}
|
||||
<div className="relative flex items-start justify-between">
|
||||
<h3 className="text-lg font-semibold tracking-wide">{card.name}</h3>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-full p-1.5 transition-colors hover:bg-white/20"
|
||||
aria-label="Editar tarjeta"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-full p-1.5 transition-colors hover:bg-white/20"
|
||||
aria-label="Eliminar tarjeta"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card number */}
|
||||
<div className="relative mt-8">
|
||||
<p className="font-mono text-2xl tracking-widest">
|
||||
**** **** **** {card.lastFourDigits}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
<div className="relative mt-6">
|
||||
<p className="text-sm text-white/70">Balance actual</p>
|
||||
<p className="text-2xl font-bold">{formatCurrency(card.currentBalance)}</p>
|
||||
</div>
|
||||
|
||||
{/* Utilization badge */}
|
||||
<div className="relative mt-4 flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 rounded-full bg-black/30 px-3 py-1">
|
||||
<div className={`h-2 w-2 rounded-full ${getUtilizationColor(utilization)}`} />
|
||||
<span className={`text-sm font-medium ${getUtilizationTextColor(utilization)}`}>
|
||||
{utilization.toFixed(0)}% usado
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-white/60">
|
||||
de {formatCurrency(card.creditLimit)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer with closing and due dates */}
|
||||
<div className="relative mt-4 flex justify-between text-xs text-white/70">
|
||||
<div>
|
||||
<span className="block">Cierre</span>
|
||||
<span className="font-medium text-white">
|
||||
{card.closingDay} ({daysUntilClosing === 0 ? 'hoy' : daysUntilClosing > 0 ? `en ${daysUntilClosing} días` : `hace ${Math.abs(daysUntilClosing)} días`})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="block">Vencimiento</span>
|
||||
<span className="font-medium text-white">
|
||||
{card.dueDay} ({daysUntilDue === 0 ? 'hoy' : daysUntilDue > 0 ? `en ${daysUntilDue} días` : `hace ${Math.abs(daysUntilDue)} días`})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajusta el brillo de un color hexadecimal
|
||||
* @param color - Color en formato hex (#RRGGBB)
|
||||
* @param amount - Cantidad a ajustar (negativo para oscurecer, positivo para aclarar)
|
||||
* @returns Color ajustado en formato hex
|
||||
*/
|
||||
function adjustColor(color: string, amount: number): string {
|
||||
const hex = color.replace('#', '')
|
||||
const r = Math.max(0, Math.min(255, parseInt(hex.substring(0, 2), 16) + amount))
|
||||
const g = Math.max(0, Math.min(255, parseInt(hex.substring(2, 4), 16) + amount))
|
||||
const b = Math.max(0, Math.min(255, parseInt(hex.substring(4, 6), 16) + amount))
|
||||
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
43
components/cards/MiniCard.tsx
Normal file
43
components/cards/MiniCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { CreditCard } from '@/lib/types'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface MiniCardProps {
|
||||
card: CreditCard
|
||||
selected?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function MiniCard({ card, selected = false, onClick }: MiniCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-all ${
|
||||
selected
|
||||
? 'border-indigo-500 bg-indigo-500/20 ring-2 ring-indigo-500/30'
|
||||
: 'border-slate-600 bg-slate-800 hover:border-slate-500 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="h-10 w-10 shrink-0 rounded-lg shadow-inner"
|
||||
style={{ backgroundColor: card.color }}
|
||||
/>
|
||||
|
||||
{/* Card info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-white">{card.name}</p>
|
||||
<p className="text-sm text-slate-400">**** {card.lastFourDigits}</p>
|
||||
</div>
|
||||
|
||||
{/* Selected indicator */}
|
||||
{selected && (
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-500">
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
5
components/cards/index.ts
Normal file
5
components/cards/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { CreditCardWidget } from './CreditCardWidget'
|
||||
export { CreditCardForm } from './CreditCardForm'
|
||||
export { CardPaymentForm } from './CardPaymentForm'
|
||||
export { MiniCard } from './MiniCard'
|
||||
export { CardSection } from './CardSection'
|
||||
63
components/dashboard/DashboardHeader.tsx
Normal file
63
components/dashboard/DashboardHeader.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { getMonthName } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
}: DashboardHeaderProps) {
|
||||
const now = new Date()
|
||||
const currentMonth = now.getMonth() + 1
|
||||
const currentYear = now.getFullYear()
|
||||
const monthName = getMonthName(currentMonth)
|
||||
|
||||
// Formatear fecha actual
|
||||
const formattedDate = new Intl.DateTimeFormat('es-AR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(now)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-b border-slate-700 bg-slate-800/50 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Dashboard
|
||||
<span className="ml-2 text-lg font-normal text-slate-400">
|
||||
{monthName} {currentYear}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm capitalize text-slate-400">
|
||||
{formattedDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-lg border border-slate-600',
|
||||
'bg-slate-700 px-4 py-2 text-sm font-medium text-white',
|
||||
'transition-colors hover:bg-slate-600',
|
||||
'focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 focus:ring-offset-slate-800',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('h-4 w-4', isRefreshing && 'animate-spin')}
|
||||
/>
|
||||
{isRefreshing ? 'Actualizando...' : 'Actualizar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
components/dashboard/ExpenseChart.tsx
Normal file
168
components/dashboard/ExpenseChart.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { FixedDebt, VariableDebt } from '@/lib/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface ExpenseChartProps {
|
||||
fixedDebts: FixedDebt[]
|
||||
variableDebts: VariableDebt[]
|
||||
}
|
||||
|
||||
// Colores por categoría
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
// Deudas fijas
|
||||
housing: '#10b981', // emerald-500
|
||||
services: '#3b82f6', // blue-500
|
||||
subscription: '#8b5cf6', // violet-500
|
||||
other: '#64748b', // slate-500
|
||||
// Deudas variables
|
||||
shopping: '#f59e0b', // amber-500
|
||||
food: '#ef4444', // red-500
|
||||
entertainment: '#ec4899', // pink-500
|
||||
health: '#06b6d4', // cyan-500
|
||||
transport: '#84cc16', // lime-500
|
||||
}
|
||||
|
||||
// Nombres de categorías en español
|
||||
const CATEGORY_NAMES: Record<string, string> = {
|
||||
housing: 'Vivienda',
|
||||
services: 'Servicios',
|
||||
subscription: 'Suscripciones',
|
||||
other: 'Otros',
|
||||
shopping: 'Compras',
|
||||
food: 'Comida',
|
||||
entertainment: 'Entretenimiento',
|
||||
health: 'Salud',
|
||||
transport: 'Transporte',
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
category: string
|
||||
}
|
||||
|
||||
export function ExpenseChart({ fixedDebts, variableDebts }: ExpenseChartProps) {
|
||||
// Agrupar gastos por categoría
|
||||
const categoryTotals = new Map<string, number>()
|
||||
|
||||
// Agregar deudas fijas no pagadas
|
||||
fixedDebts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.forEach((debt) => {
|
||||
const current = categoryTotals.get(debt.category) || 0
|
||||
categoryTotals.set(debt.category, current + debt.amount)
|
||||
})
|
||||
|
||||
// Agregar deudas variables no pagadas
|
||||
variableDebts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.forEach((debt) => {
|
||||
const current = categoryTotals.get(debt.category) || 0
|
||||
categoryTotals.set(debt.category, current + debt.amount)
|
||||
})
|
||||
|
||||
// Convertir a formato de datos para el gráfico
|
||||
const data: ChartData[] = Array.from(categoryTotals.entries())
|
||||
.map(([category, value]) => ({
|
||||
name: CATEGORY_NAMES[category] || category,
|
||||
value,
|
||||
color: CATEGORY_COLORS[category] || '#64748b',
|
||||
category,
|
||||
}))
|
||||
.filter((item) => item.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
|
||||
// Calcular total
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0)
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center rounded-xl border border-slate-700 bg-slate-800">
|
||||
<p className="text-slate-500">No hay gastos pendientes</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-white">
|
||||
Distribución de Gastos
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
{/* Gráfico de dona */}
|
||||
<div className="h-64 w-full lg:w-1/2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) =>
|
||||
typeof value === 'number' ? formatCurrency(value) : value
|
||||
}
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Leyenda */}
|
||||
<div className="flex w-full flex-col justify-center gap-3 lg:w-1/2">
|
||||
{data.map((item) => {
|
||||
const percentage = total > 0 ? (item.value / total) * 100 : 0
|
||||
return (
|
||||
<div key={item.category} className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatCurrency(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Total */}
|
||||
<div className="mt-4 border-t border-slate-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-400">Total</span>
|
||||
<span className="font-mono text-lg font-bold text-emerald-400">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
components/dashboard/MetricCard.tsx
Normal file
73
components/dashboard/MetricCard.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { LucideIcon, TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
amount: number
|
||||
subtitle?: string
|
||||
trend?: {
|
||||
value: number
|
||||
isPositive: boolean
|
||||
}
|
||||
icon: LucideIcon
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
amount,
|
||||
subtitle,
|
||||
trend,
|
||||
icon: Icon,
|
||||
color = 'text-emerald-400',
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-slate-700 bg-slate-800 p-6 shadow-lg">
|
||||
{/* Icono en esquina superior derecha */}
|
||||
<div className={cn('absolute right-4 top-4', color)}>
|
||||
<Icon className="h-10 w-10 opacity-80" />
|
||||
</div>
|
||||
|
||||
{/* Contenido */}
|
||||
<div className="relative">
|
||||
{/* Título */}
|
||||
<h3 className="text-sm font-medium text-slate-400">{title}</h3>
|
||||
|
||||
{/* Monto */}
|
||||
<p className="mt-2 font-mono text-3xl font-bold text-emerald-400">
|
||||
{formatCurrency(amount)}
|
||||
</p>
|
||||
|
||||
{/* Subtítulo */}
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
|
||||
)}
|
||||
|
||||
{/* Indicador de tendencia */}
|
||||
{trend && (
|
||||
<div className="mt-3 flex items-center gap-1.5">
|
||||
{trend.isPositive ? (
|
||||
<>
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium text-emerald-500">
|
||||
+{trend.value}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown className="h-4 w-4 text-rose-500" />
|
||||
<span className="text-sm font-medium text-rose-500">
|
||||
-{trend.value}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-slate-500">vs mes anterior</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
components/dashboard/QuickActions.tsx
Normal file
68
components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, CreditCard, Wallet } from 'lucide-react'
|
||||
|
||||
interface QuickActionsProps {
|
||||
onAddDebt: () => void
|
||||
onAddCard: () => void
|
||||
onAddPayment: () => void
|
||||
}
|
||||
|
||||
interface ActionButton {
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
onClick: () => void
|
||||
color: string
|
||||
}
|
||||
|
||||
export function QuickActions({
|
||||
onAddDebt,
|
||||
onAddCard,
|
||||
onAddPayment,
|
||||
}: QuickActionsProps) {
|
||||
const actions: ActionButton[] = [
|
||||
{
|
||||
label: 'Agregar Deuda',
|
||||
icon: Plus,
|
||||
onClick: onAddDebt,
|
||||
color: 'bg-emerald-500 hover:bg-emerald-600',
|
||||
},
|
||||
{
|
||||
label: 'Nueva Tarjeta',
|
||||
icon: CreditCard,
|
||||
onClick: onAddCard,
|
||||
color: 'bg-blue-500 hover:bg-blue-600',
|
||||
},
|
||||
{
|
||||
label: 'Registrar Pago',
|
||||
icon: Wallet,
|
||||
onClick: onAddPayment,
|
||||
color: 'bg-violet-500 hover:bg-violet-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
className={`
|
||||
group flex flex-col items-center gap-3 rounded-xl p-6
|
||||
transition-all duration-200 ease-out
|
||||
${action.color}
|
||||
focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-slate-800
|
||||
`}
|
||||
>
|
||||
<div className="rounded-full bg-white/20 p-4 transition-transform group-hover:scale-110">
|
||||
<Icon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<span className="font-medium text-white">{action.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
components/dashboard/RecentActivity.tsx
Normal file
168
components/dashboard/RecentActivity.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowDownLeft, ArrowUpRight, CreditCard, Wallet } from 'lucide-react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { formatCurrency, formatShortDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RecentActivityProps {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
id: string
|
||||
type: 'fixed_debt' | 'variable_debt' | 'card_payment'
|
||||
title: string
|
||||
amount: number
|
||||
date: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function RecentActivity({ limit = 5 }: RecentActivityProps) {
|
||||
const { fixedDebts, variableDebts, cardPayments, creditCards } =
|
||||
useFinanzasStore()
|
||||
|
||||
// Combinar todas las actividades
|
||||
const activities: ActivityItem[] = [
|
||||
// Deudas fijas recientes
|
||||
...fixedDebts.slice(0, limit).map((debt) => ({
|
||||
id: debt.id,
|
||||
type: 'fixed_debt' as const,
|
||||
title: debt.name,
|
||||
amount: debt.amount,
|
||||
date: new Date().toISOString(), // Usar fecha actual ya que fixedDebt no tiene fecha de creación
|
||||
description: `Vence el día ${debt.dueDay}`,
|
||||
})),
|
||||
|
||||
// Deudas variables recientes
|
||||
...variableDebts.slice(0, limit).map((debt) => ({
|
||||
id: debt.id,
|
||||
type: 'variable_debt' as const,
|
||||
title: debt.name,
|
||||
amount: debt.amount,
|
||||
date: debt.date,
|
||||
description: debt.notes,
|
||||
})),
|
||||
|
||||
// Pagos de tarjetas recientes
|
||||
...cardPayments.slice(0, limit).map((payment) => {
|
||||
const card = creditCards.find((c) => c.id === payment.cardId)
|
||||
return {
|
||||
id: payment.id,
|
||||
type: 'card_payment' as const,
|
||||
title: `Pago - ${card?.name || 'Tarjeta'}`,
|
||||
amount: payment.amount,
|
||||
date: payment.date,
|
||||
description: payment.description,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
// Ordenar por fecha (más recientes primero)
|
||||
const sortedActivities = activities
|
||||
.sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
)
|
||||
.slice(0, limit)
|
||||
|
||||
// Configuración por tipo de actividad
|
||||
const activityConfig = {
|
||||
fixed_debt: {
|
||||
icon: Wallet,
|
||||
label: 'Deuda Fija',
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-400/10',
|
||||
},
|
||||
variable_debt: {
|
||||
icon: ArrowUpRight,
|
||||
label: 'Gasto',
|
||||
color: 'text-rose-400',
|
||||
bgColor: 'bg-rose-400/10',
|
||||
},
|
||||
card_payment: {
|
||||
icon: CreditCard,
|
||||
label: 'Pago Tarjeta',
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-400/10',
|
||||
},
|
||||
}
|
||||
|
||||
if (sortedActivities.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-white">
|
||||
Actividad Reciente
|
||||
</h3>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-slate-500">No hay actividad reciente</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-white">
|
||||
Actividad Reciente
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedActivities.map((activity) => {
|
||||
const config = activityConfig[activity.type]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-center gap-4 rounded-lg border border-slate-700/50 bg-slate-700/30 p-4 transition-colors hover:bg-slate-700/50"
|
||||
>
|
||||
{/* Icono */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full',
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-5 w-5', config.color)} />
|
||||
</div>
|
||||
|
||||
{/* Contenido */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="truncate font-medium text-white">
|
||||
{activity.title}
|
||||
</h4>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 font-mono font-medium',
|
||||
activity.type === 'card_payment'
|
||||
? 'text-emerald-400'
|
||||
: 'text-rose-400'
|
||||
)}
|
||||
>
|
||||
{activity.type === 'card_payment' ? '+' : '-'}
|
||||
{formatCurrency(activity.amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-slate-400">
|
||||
<span className={cn('text-xs', config.color)}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatShortDate(activity.date)}</span>
|
||||
{activity.description && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="truncate">{activity.description}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
components/dashboard/SummarySection.tsx
Normal file
156
components/dashboard/SummarySection.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { AlertCircle, CreditCard, PiggyBank, Wallet } from 'lucide-react'
|
||||
import { MetricCard } from './MetricCard'
|
||||
import { ExpenseChart } from './ExpenseChart'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import {
|
||||
calculateTotalFixedDebts,
|
||||
calculateTotalVariableDebts,
|
||||
} from '@/lib/utils'
|
||||
import {
|
||||
getCurrentMonthBudget,
|
||||
calculateCurrentSpending,
|
||||
} from '@/lib/alerts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SummarySection() {
|
||||
const {
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
creditCards,
|
||||
monthlyBudgets,
|
||||
alerts,
|
||||
currentMonth,
|
||||
currentYear,
|
||||
} = useFinanzasStore()
|
||||
|
||||
// Calcular métricas
|
||||
const totalFixedDebts = calculateTotalFixedDebts(fixedDebts)
|
||||
const totalVariableDebts = calculateTotalVariableDebts(variableDebts)
|
||||
const totalPendingDebts = totalFixedDebts + totalVariableDebts
|
||||
|
||||
const totalCardBalance = creditCards.reduce(
|
||||
(sum, card) => sum + card.currentBalance,
|
||||
0
|
||||
)
|
||||
|
||||
const currentBudget = getCurrentMonthBudget(
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
)
|
||||
|
||||
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
|
||||
|
||||
// Presupuesto disponible (ingresos - gastos actuales)
|
||||
const availableBudget = currentBudget
|
||||
? currentBudget.totalIncome - currentSpending
|
||||
: 0
|
||||
|
||||
// Meta de ahorro proyectada
|
||||
const projectedSavings = currentBudget
|
||||
? currentBudget.totalIncome - currentSpending
|
||||
: 0
|
||||
|
||||
const savingsGoal = currentBudget?.savingsGoal || 0
|
||||
|
||||
// Alertas no leídas (primeras 3)
|
||||
const unreadAlerts = alerts
|
||||
.filter((alert) => !alert.isRead)
|
||||
.slice(0, 3)
|
||||
|
||||
// Colores por severidad de alerta
|
||||
const severityColors = {
|
||||
danger: 'border-rose-500 bg-rose-500/10 text-rose-400',
|
||||
warning: 'border-amber-500 bg-amber-500/10 text-amber-400',
|
||||
info: 'border-blue-500 bg-blue-500/10 text-blue-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Grid de métricas */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
title="Deudas Pendientes"
|
||||
amount={totalPendingDebts}
|
||||
subtitle={`${fixedDebts.filter((d) => !d.isPaid).length + variableDebts.filter((d) => !d.isPaid).length} pagos pendientes`}
|
||||
icon={Wallet}
|
||||
color="text-rose-400"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Balance en Tarjetas"
|
||||
amount={totalCardBalance}
|
||||
subtitle={`${creditCards.length} tarjetas activas`}
|
||||
icon={CreditCard}
|
||||
color="text-blue-400"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Presupuesto Disponible"
|
||||
amount={availableBudget}
|
||||
subtitle={
|
||||
currentBudget
|
||||
? `de ${currentBudget.totalIncome.toLocaleString('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
})} ingresos`
|
||||
: 'Sin presupuesto definido'
|
||||
}
|
||||
icon={PiggyBank}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Meta de Ahorro"
|
||||
amount={projectedSavings}
|
||||
subtitle={
|
||||
savingsGoal > 0
|
||||
? `${((projectedSavings / savingsGoal) * 100).toFixed(0)}% de la meta`
|
||||
: 'Sin meta definida'
|
||||
}
|
||||
icon={PiggyBank}
|
||||
color="text-violet-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráfico y alertas */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Gráfico de distribución */}
|
||||
<ExpenseChart fixedDebts={fixedDebts} variableDebts={variableDebts} />
|
||||
|
||||
{/* Alertas destacadas */}
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-800 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-white">
|
||||
Alertas Destacadas
|
||||
</h3>
|
||||
|
||||
{unreadAlerts.length === 0 ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<p className="text-slate-500">No hay alertas pendientes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{unreadAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-4',
|
||||
severityColors[alert.severity]
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium">{alert.title}</h4>
|
||||
<p className="mt-1 text-sm opacity-90">{alert.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
components/dashboard/index.ts
Normal file
6
components/dashboard/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { MetricCard } from './MetricCard'
|
||||
export { DashboardHeader } from './DashboardHeader'
|
||||
export { ExpenseChart } from './ExpenseChart'
|
||||
export { QuickActions } from './QuickActions'
|
||||
export { SummarySection } from './SummarySection'
|
||||
export { RecentActivity } from './RecentActivity'
|
||||
140
components/debts/DebtCard.tsx
Normal file
140
components/debts/DebtCard.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { FixedDebt, VariableDebt } from '@/lib/types'
|
||||
import { formatCurrency, formatShortDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Pencil, Trash2, Check } from 'lucide-react'
|
||||
|
||||
interface DebtCardProps {
|
||||
debt: FixedDebt | VariableDebt
|
||||
type: 'fixed' | 'variable'
|
||||
onTogglePaid: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const fixedCategoryColors: Record<string, string> = {
|
||||
housing: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
services: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
subscription: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
other: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
}
|
||||
|
||||
const variableCategoryColors: Record<string, string> = {
|
||||
shopping: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
|
||||
food: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
entertainment: 'bg-indigo-500/20 text-indigo-400 border-indigo-500/30',
|
||||
health: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
transport: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
other: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
housing: 'Vivienda',
|
||||
services: 'Servicios',
|
||||
subscription: 'Suscripción',
|
||||
shopping: 'Compras',
|
||||
food: 'Comida',
|
||||
entertainment: 'Entretenimiento',
|
||||
health: 'Salud',
|
||||
transport: 'Transporte',
|
||||
other: 'Otro',
|
||||
}
|
||||
|
||||
export function DebtCard({ debt, type, onTogglePaid, onEdit, onDelete }: DebtCardProps) {
|
||||
const isFixed = type === 'fixed'
|
||||
const categoryColors = isFixed ? fixedCategoryColors : variableCategoryColors
|
||||
const categoryColor = categoryColors[debt.category] || categoryColors.other
|
||||
|
||||
const getDueInfo = () => {
|
||||
if (isFixed) {
|
||||
const fixedDebt = debt as FixedDebt
|
||||
return `Vence día ${fixedDebt.dueDay}`
|
||||
} else {
|
||||
const variableDebt = debt as VariableDebt
|
||||
return formatShortDate(variableDebt.date)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative bg-slate-800 border border-slate-700/50 rounded-lg p-4',
|
||||
'transition-all duration-200 hover:border-slate-600',
|
||||
debt.isPaid && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={onTogglePaid}
|
||||
className={cn(
|
||||
'mt-1 w-5 h-5 rounded border-2 flex items-center justify-center',
|
||||
'transition-colors duration-200',
|
||||
debt.isPaid
|
||||
? 'bg-emerald-500 border-emerald-500'
|
||||
: 'border-slate-500 hover:border-emerald-400'
|
||||
)}
|
||||
aria-label={debt.isPaid ? 'Marcar como no pagada' : 'Marcar como pagada'}
|
||||
>
|
||||
{debt.isPaid && <Check className="w-3 h-3 text-white" />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3
|
||||
className={cn(
|
||||
'text-white font-medium truncate',
|
||||
debt.isPaid && 'line-through text-slate-400'
|
||||
)}
|
||||
>
|
||||
{debt.name}
|
||||
</h3>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{getDueInfo()}</p>
|
||||
</div>
|
||||
<span className="font-mono text-emerald-400 font-semibold whitespace-nowrap">
|
||||
{formatCurrency(debt.amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border',
|
||||
categoryColor
|
||||
)}
|
||||
>
|
||||
{categoryLabels[debt.category] || debt.category}
|
||||
</span>
|
||||
|
||||
{isFixed && (debt as FixedDebt).isAutoDebit && (
|
||||
<span className="text-xs text-slate-500">
|
||||
Débito automático
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 text-slate-400 hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
|
||||
aria-label="Editar"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
aria-label="Eliminar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
components/debts/DebtSection.tsx
Normal file
224
components/debts/DebtSection.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { FixedDebt, VariableDebt } from '@/lib/types'
|
||||
import { DebtCard } from './DebtCard'
|
||||
import { FixedDebtForm } from './FixedDebtForm'
|
||||
import { VariableDebtForm } from './VariableDebtForm'
|
||||
import { Plus, Wallet } from 'lucide-react'
|
||||
import { cn, formatCurrency, calculateTotalFixedDebts, calculateTotalVariableDebts } from '@/lib/utils'
|
||||
|
||||
type DebtType = 'fixed' | 'variable'
|
||||
|
||||
export function DebtSection() {
|
||||
const [activeTab, setActiveTab] = useState<DebtType>('fixed')
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingDebt, setEditingDebt] = useState<FixedDebt | VariableDebt | null>(null)
|
||||
|
||||
const {
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
addFixedDebt,
|
||||
updateFixedDebt,
|
||||
deleteFixedDebt,
|
||||
toggleFixedDebtPaid,
|
||||
addVariableDebt,
|
||||
updateVariableDebt,
|
||||
deleteVariableDebt,
|
||||
toggleVariableDebtPaid,
|
||||
} = useFinanzasStore()
|
||||
|
||||
const currentDebts = activeTab === 'fixed' ? fixedDebts : variableDebts
|
||||
const totalUnpaid = activeTab === 'fixed'
|
||||
? calculateTotalFixedDebts(fixedDebts)
|
||||
: calculateTotalVariableDebts(variableDebts)
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingDebt(null)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (debt: FixedDebt | VariableDebt) => {
|
||||
setEditingDebt(debt)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingDebt(null)
|
||||
}
|
||||
|
||||
const handleSubmitFixed = (data: Omit<FixedDebt, 'id' | 'isPaid'>) => {
|
||||
if (editingDebt?.id) {
|
||||
updateFixedDebt(editingDebt.id, data)
|
||||
} else {
|
||||
addFixedDebt({ ...data, isPaid: false })
|
||||
}
|
||||
handleCloseModal()
|
||||
}
|
||||
|
||||
const handleSubmitVariable = (data: Omit<VariableDebt, 'id' | 'isPaid'>) => {
|
||||
if (editingDebt?.id) {
|
||||
updateVariableDebt(editingDebt.id, data)
|
||||
} else {
|
||||
addVariableDebt({ ...data, isPaid: false })
|
||||
}
|
||||
handleCloseModal()
|
||||
}
|
||||
|
||||
const handleDelete = (debt: FixedDebt | VariableDebt) => {
|
||||
if (confirm('¿Estás seguro de que deseas eliminar esta deuda?')) {
|
||||
if (activeTab === 'fixed') {
|
||||
deleteFixedDebt(debt.id)
|
||||
} else {
|
||||
deleteVariableDebt(debt.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePaid = (debt: FixedDebt | VariableDebt) => {
|
||||
if (activeTab === 'fixed') {
|
||||
toggleFixedDebtPaid(debt.id)
|
||||
} else {
|
||||
toggleVariableDebtPaid(debt.id)
|
||||
}
|
||||
}
|
||||
|
||||
const paidCount = currentDebts.filter(d => d.isPaid).length
|
||||
const unpaidCount = currentDebts.filter(d => !d.isPaid).length
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 min-h-screen p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Deudas</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Gestiona tus gastos fijos y variables
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
|
||||
'hover:bg-blue-500 transition-colors'
|
||||
)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<p className="text-slate-400 text-sm">Total pendiente</p>
|
||||
<p className="text-xl font-mono font-semibold text-emerald-400 mt-1">
|
||||
{formatCurrency(totalUnpaid)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<p className="text-slate-400 text-sm">Pagadas</p>
|
||||
<p className="text-xl font-semibold text-blue-400 mt-1">
|
||||
{paidCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-800 border border-slate-700/50 rounded-lg p-4">
|
||||
<p className="text-slate-400 text-sm">Pendientes</p>
|
||||
<p className="text-xl font-semibold text-orange-400 mt-1">
|
||||
{unpaidCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('fixed')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||
activeTab === 'fixed'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Fijas ({fixedDebts.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('variable')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||
activeTab === 'variable'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Variables ({variableDebts.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Debt List */}
|
||||
<div className="space-y-3">
|
||||
{currentDebts.length === 0 ? (
|
||||
<div className="text-center py-16 bg-slate-800/50 border border-slate-700/50 rounded-lg">
|
||||
<Wallet className="w-12 h-12 text-slate-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-slate-300">
|
||||
No hay deudas {activeTab === 'fixed' ? 'fijas' : 'variables'}
|
||||
</h3>
|
||||
<p className="text-slate-500 mt-2">
|
||||
Haz clic en "Agregar" para crear una nueva deuda
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
currentDebts.map((debt) => (
|
||||
<DebtCard
|
||||
key={debt.id}
|
||||
debt={debt}
|
||||
type={activeTab}
|
||||
onTogglePaid={() => handleTogglePaid(debt)}
|
||||
onEdit={() => handleEdit(debt)}
|
||||
onDelete={() => handleDelete(debt)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
<div className="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
{editingDebt
|
||||
? 'Editar deuda'
|
||||
: activeTab === 'fixed'
|
||||
? 'Nueva deuda fija'
|
||||
: 'Nueva deuda variable'}
|
||||
</h2>
|
||||
{activeTab === 'fixed' ? (
|
||||
<FixedDebtForm
|
||||
initialData={editingDebt as Partial<FixedDebt> | undefined}
|
||||
onSubmit={handleSubmitFixed}
|
||||
onCancel={handleCloseModal}
|
||||
/>
|
||||
) : (
|
||||
<VariableDebtForm
|
||||
initialData={editingDebt as Partial<VariableDebt> | undefined}
|
||||
onSubmit={handleSubmitVariable}
|
||||
onCancel={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
components/debts/FixedDebtForm.tsx
Normal file
212
components/debts/FixedDebtForm.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FixedDebt } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FixedDebtFormProps {
|
||||
initialData?: Partial<FixedDebt>
|
||||
onSubmit: (data: Omit<FixedDebt, 'id' | 'isPaid'>) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ value: 'housing', label: 'Vivienda' },
|
||||
{ value: 'services', label: 'Servicios' },
|
||||
{ value: 'subscription', label: 'Suscripción' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
] as const
|
||||
|
||||
export function FixedDebtForm({ initialData, onSubmit, onCancel }: FixedDebtFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || '',
|
||||
amount: initialData?.amount || 0,
|
||||
dueDay: initialData?.dueDay || 1,
|
||||
category: initialData?.category || 'other',
|
||||
isAutoDebit: initialData?.isAutoDebit || false,
|
||||
notes: initialData?.notes || '',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'El nombre es requerido'
|
||||
}
|
||||
|
||||
if (formData.amount <= 0) {
|
||||
newErrors.amount = 'El monto debe ser mayor a 0'
|
||||
}
|
||||
|
||||
if (formData.dueDay < 1 || formData.dueDay > 31) {
|
||||
newErrors.dueDay = 'El día debe estar entre 1 y 31'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (validate()) {
|
||||
onSubmit(formData)
|
||||
}
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof typeof formData>(
|
||||
field: K,
|
||||
value: typeof formData[K]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[field]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Nombre <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.name ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
placeholder="Ej: Alquiler, Internet, etc."
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="amount" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Monto <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="amount"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.amount || ''}
|
||||
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.amount ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.amount && <p className="mt-1 text-sm text-red-400">{errors.amount}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="dueDay" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Día de vencimiento <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="dueDay"
|
||||
min="1"
|
||||
max="31"
|
||||
value={formData.dueDay}
|
||||
onChange={(e) => updateField('dueDay', parseInt(e.target.value) || 1)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.dueDay ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
placeholder="1"
|
||||
/>
|
||||
{errors.dueDay && <p className="mt-1 text-sm text-red-400">{errors.dueDay}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Categoría
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => updateField('category', e.target.value as FixedDebt['category'])}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500'
|
||||
)}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isAutoDebit"
|
||||
checked={formData.isAutoDebit}
|
||||
onChange={(e) => updateField('isAutoDebit', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-slate-600 bg-slate-800 text-blue-500 focus:ring-blue-500/50"
|
||||
/>
|
||||
<label htmlFor="isAutoDebit" className="text-sm text-slate-300">
|
||||
Tiene débito automático
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="notes" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Notas <span className="text-slate-500">(opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
rows={3}
|
||||
value={formData.notes}
|
||||
onChange={(e) => updateField('notes', e.target.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
'resize-none'
|
||||
)}
|
||||
placeholder="Notas adicionales..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 bg-slate-700 text-slate-200 rounded-lg font-medium',
|
||||
'hover:bg-slate-600 transition-colors'
|
||||
)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
|
||||
'hover:bg-blue-500 transition-colors'
|
||||
)}
|
||||
>
|
||||
{initialData?.id ? 'Guardar cambios' : 'Agregar deuda'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
197
components/debts/VariableDebtForm.tsx
Normal file
197
components/debts/VariableDebtForm.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { VariableDebt } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface VariableDebtFormProps {
|
||||
initialData?: Partial<VariableDebt>
|
||||
onSubmit: (data: Omit<VariableDebt, 'id' | 'isPaid'>) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ value: 'shopping', label: 'Compras' },
|
||||
{ value: 'food', label: 'Comida' },
|
||||
{ value: 'entertainment', label: 'Entretenimiento' },
|
||||
{ value: 'health', label: 'Salud' },
|
||||
{ value: 'transport', label: 'Transporte' },
|
||||
{ value: 'other', label: 'Otro' },
|
||||
] as const
|
||||
|
||||
export function VariableDebtForm({ initialData, onSubmit, onCancel }: VariableDebtFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || '',
|
||||
amount: initialData?.amount || 0,
|
||||
date: initialData?.date || new Date().toISOString().split('T')[0],
|
||||
category: initialData?.category || 'other',
|
||||
notes: initialData?.notes || '',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'El nombre es requerido'
|
||||
}
|
||||
|
||||
if (formData.amount <= 0) {
|
||||
newErrors.amount = 'El monto debe ser mayor a 0'
|
||||
}
|
||||
|
||||
if (!formData.date) {
|
||||
newErrors.date = 'La fecha es requerida'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (validate()) {
|
||||
onSubmit(formData)
|
||||
}
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof typeof formData>(
|
||||
field: K,
|
||||
value: typeof formData[K]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[field]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Nombre <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.name ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
placeholder="Ej: Supermercado, Cena, etc."
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="amount" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Monto <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="amount"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.amount || ''}
|
||||
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.amount ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.amount && <p className="mt-1 text-sm text-red-400">{errors.amount}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Fecha <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => updateField('date', e.target.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border rounded-lg text-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
errors.date ? 'border-red-500' : 'border-slate-600'
|
||||
)}
|
||||
/>
|
||||
{errors.date && <p className="mt-1 text-sm text-red-400">{errors.date}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Categoría
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => updateField('category', e.target.value as VariableDebt['category'])}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500'
|
||||
)}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="notes" className="block text-sm font-medium text-slate-300 mb-1">
|
||||
Notas <span className="text-slate-500">(opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
rows={3}
|
||||
value={formData.notes}
|
||||
onChange={(e) => updateField('notes', e.target.value)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500',
|
||||
'resize-none'
|
||||
)}
|
||||
placeholder="Notas adicionales..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 bg-slate-700 text-slate-200 rounded-lg font-medium',
|
||||
'hover:bg-slate-600 transition-colors'
|
||||
)}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium',
|
||||
'hover:bg-blue-500 transition-colors'
|
||||
)}
|
||||
>
|
||||
{initialData?.id ? 'Guardar cambios' : 'Agregar deuda'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
4
components/debts/index.ts
Normal file
4
components/debts/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DebtCard } from './DebtCard';
|
||||
export { DebtSection } from './DebtSection';
|
||||
export { FixedDebtForm } from './FixedDebtForm';
|
||||
export { VariableDebtForm } from './VariableDebtForm';
|
||||
57
components/layout/Header.tsx
Normal file
57
components/layout/Header.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import { Logo } from './Logo';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick, title }: HeaderProps) {
|
||||
const currentDate = format(new Date(), "EEEE, d 'de' MMMM 'de' yyyy", {
|
||||
locale: es,
|
||||
});
|
||||
|
||||
// Capitalizar primera letra
|
||||
const formattedDate =
|
||||
currentDate.charAt(0).toUpperCase() + currentDate.slice(1);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-slate-900/95 backdrop-blur-sm border-b border-slate-800">
|
||||
<div className="flex items-center justify-between h-16 px-4 md:px-6">
|
||||
{/* Left section */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-2 -ml-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
aria-label="Abrir menú"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="lg:hidden">
|
||||
<Logo size="sm" showText={false} />
|
||||
</div>
|
||||
<h1 className="text-lg md:text-xl font-semibold text-slate-100">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Logo size="sm" showText />
|
||||
</div>
|
||||
<time className="text-sm text-slate-400 hidden sm:block">
|
||||
{formattedDate}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
36
components/layout/Logo.tsx
Normal file
36
components/layout/Logo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Wallet } from 'lucide-react';
|
||||
|
||||
interface LogoProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: {
|
||||
icon: 24,
|
||||
text: 'text-lg',
|
||||
},
|
||||
md: {
|
||||
icon: 32,
|
||||
text: 'text-xl',
|
||||
},
|
||||
lg: {
|
||||
icon: 40,
|
||||
text: 'text-2xl',
|
||||
},
|
||||
};
|
||||
|
||||
export function Logo({ size = 'md', showText = true }: LogoProps) {
|
||||
const { icon, text } = sizeMap[size];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<Wallet className="text-emerald-500" size={icon} strokeWidth={2} />
|
||||
</div>
|
||||
{showText && (
|
||||
<span className={`font-bold text-slate-100 ${text}`}>Finanzas</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
components/layout/MobileNav.tsx
Normal file
72
components/layout/MobileNav.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
PiggyBank,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
interface MobileNavProps {
|
||||
unreadAlertsCount?: number;
|
||||
}
|
||||
|
||||
const navigationItems = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Deudas', href: '/debts', icon: Wallet },
|
||||
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
|
||||
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
|
||||
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
|
||||
];
|
||||
|
||||
export function MobileNav({ unreadAlertsCount = 0 }: MobileNavProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-40 bg-slate-900 border-t border-slate-800 lg:hidden">
|
||||
<ul className="flex items-center justify-around h-16">
|
||||
{navigationItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li key={item.name} className="flex-1">
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-1 py-2
|
||||
transition-colors relative
|
||||
${
|
||||
active
|
||||
? 'text-emerald-500'
|
||||
: 'text-slate-400 hover:text-slate-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="relative">
|
||||
<Icon className="w-6 h-6" />
|
||||
{item.hasBadge && unreadAlertsCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-semibold bg-red-500 text-white rounded-full">
|
||||
{unreadAlertsCount > 99 ? '99+' : unreadAlertsCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium">{item.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
18
components/layout/PageContainer.tsx
Normal file
18
components/layout/PageContainer.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function PageContainer({ children, title }: PageContainerProps) {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-950">
|
||||
<div className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
|
||||
<div className="space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
components/layout/Section.tsx
Normal file
35
components/layout/Section.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface SectionAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
action?: SectionAction;
|
||||
}
|
||||
|
||||
export function Section({ title, children, action }: SectionProps) {
|
||||
return (
|
||||
<section className="bg-slate-900 rounded-lg border border-slate-800">
|
||||
<div className="flex items-center justify-between px-4 py-3 md:px-6 md:py-4 border-b border-slate-800">
|
||||
<h2 className="text-base md:text-lg font-semibold text-slate-100">
|
||||
{title}
|
||||
</h2>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="px-3 py-1.5 text-sm font-medium text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition-colors"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 md:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
122
components/layout/Sidebar.tsx
Normal file
122
components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
PiggyBank,
|
||||
Bell,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Logo } from './Logo';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
unreadAlertsCount?: number;
|
||||
}
|
||||
|
||||
const navigationItems = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Deudas', href: '/debts', icon: Wallet },
|
||||
{ name: 'Tarjetas', href: '/cards', icon: CreditCard },
|
||||
{ name: 'Presupuesto', href: '/budget', icon: PiggyBank },
|
||||
{ name: 'Alertas', href: '/alerts', icon: Bell, hasBadge: true },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
unreadAlertsCount = 0,
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed top-0 left-0 z-50 h-full w-64 bg-slate-900 border-r border-slate-800
|
||||
transform transition-transform duration-300 ease-in-out
|
||||
lg:translate-x-0 lg:static lg:h-screen
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<Logo size="md" showText />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
aria-label="Cerrar menú"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3">
|
||||
<ul className="space-y-1">
|
||||
{navigationItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
transition-colors relative
|
||||
${
|
||||
active
|
||||
? 'bg-slate-800 text-emerald-400 border-l-2 border-emerald-500'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-slate-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="flex-1">{item.name}</span>
|
||||
{item.hasBadge && unreadAlertsCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold bg-red-500 text-white rounded-full">
|
||||
{unreadAlertsCount > 99 ? '99+' : unreadAlertsCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-slate-800">
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
Finanzas v{process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
6
components/layout/index.ts
Normal file
6
components/layout/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { Header } from './Header';
|
||||
export { MobileNav } from './MobileNav';
|
||||
export { Logo } from './Logo';
|
||||
export { PageContainer } from './PageContainer';
|
||||
export { Section } from './Section';
|
||||
196
components/modals/AddCardModal.tsx
Normal file
196
components/modals/AddCardModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { X, CreditCard, Calendar, DollarSign, Palette } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AddCardModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
{ name: 'Slate', value: '#64748b' },
|
||||
{ name: 'Blue', value: '#3b82f6' },
|
||||
{ name: 'Cyan', value: '#06b6d4' },
|
||||
{ name: 'Emerald', value: '#10b981' },
|
||||
{ name: 'Violet', value: '#8b5cf6' },
|
||||
{ name: 'Rose', value: '#f43f5e' },
|
||||
{ name: 'Amber', value: '#f59e0b' },
|
||||
]
|
||||
|
||||
export function AddCardModal({ isOpen, onClose }: AddCardModalProps) {
|
||||
const addCreditCard = useFinanzasStore((state) => state.addCreditCard)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [lastFour, setLastFour] = useState('')
|
||||
const [limit, setLimit] = useState('')
|
||||
const [closingDay, setClosingDay] = useState('')
|
||||
const [dueDay, setDueDay] = useState('')
|
||||
const [selectedColor, setSelectedColor] = useState(COLORS[1].value)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name || !limit || !closingDay || !dueDay) return
|
||||
|
||||
addCreditCard({
|
||||
name,
|
||||
lastFourDigits: lastFour || '****',
|
||||
closingDay: parseInt(closingDay),
|
||||
dueDay: parseInt(dueDay),
|
||||
currentBalance: 0,
|
||||
creditLimit: parseFloat(limit),
|
||||
color: selectedColor
|
||||
})
|
||||
|
||||
// Reset
|
||||
setName('')
|
||||
setLastFour('')
|
||||
setLimit('')
|
||||
setClosingDay('')
|
||||
setDueDay('')
|
||||
setSelectedColor(COLORS[1].value)
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
||||
<CreditCard className="text-cyan-500" /> Nueva Tarjeta
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
|
||||
{/* Name & Last 4 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Nombre Banco / Tarjeta</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: Visa Santander"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Ult. 4 Dig.</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={4}
|
||||
placeholder="1234"
|
||||
value={lastFour}
|
||||
onChange={(e) => setLastFour(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none text-center tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limit */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Límite de Crédito</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white text-lg font-mono outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Closing Day */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Día Cierre</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
placeholder="20"
|
||||
value={closingDay}
|
||||
onChange={(e) => setClosingDay(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
|
||||
required
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Day */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Día Vencimiento</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
placeholder="5"
|
||||
value={dueDay}
|
||||
onChange={(e) => setDueDay(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
|
||||
required
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<Palette size={12} /> Color de Tarjeta
|
||||
</label>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full border-2 transition-all",
|
||||
selectedColor === color.value
|
||||
? "border-white scale-110 shadow-lg"
|
||||
: "border-transparent opacity-70 hover:opacity-100 hover:scale-105"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Crear Tarjeta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
232
components/modals/AddDebtModal.tsx
Normal file
232
components/modals/AddDebtModal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { X, Calendar, DollarSign, Tag, FileText, CheckCircle2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AddDebtModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type DebtType = 'fixed' | 'variable'
|
||||
|
||||
export function AddDebtModal({ isOpen, onClose }: AddDebtModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<DebtType>('variable')
|
||||
const [name, setName] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [dateStr, setDateStr] = useState(new Date().toISOString().split('T')[0]) // For variable: YYYY-MM-DD
|
||||
const [dueDay, setDueDay] = useState('1') // For fixed: 1-31
|
||||
const [categoryFixed, setCategoryFixed] = useState('housing')
|
||||
const [categoryVariable, setCategoryVariable] = useState('shopping')
|
||||
const [isAutoDebit, setIsAutoDebit] = useState(false)
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const addFixedDebt = useFinanzasStore((state) => state.addFixedDebt)
|
||||
const addVariableDebt = useFinanzasStore((state) => state.addVariableDebt)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name || !amount) return
|
||||
|
||||
const numAmount = parseFloat(amount)
|
||||
if (isNaN(numAmount)) return
|
||||
|
||||
if (activeTab === 'fixed') {
|
||||
addFixedDebt({
|
||||
name,
|
||||
amount: numAmount,
|
||||
dueDay: parseInt(dueDay),
|
||||
category: categoryFixed as any,
|
||||
isAutoDebit,
|
||||
isPaid: false,
|
||||
notes: notes || undefined
|
||||
})
|
||||
} else {
|
||||
addVariableDebt({
|
||||
name,
|
||||
amount: numAmount,
|
||||
date: new Date(dateStr).toISOString(),
|
||||
category: categoryVariable as any,
|
||||
isPaid: false,
|
||||
notes: notes || undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Reset and Close
|
||||
setName('')
|
||||
setAmount('')
|
||||
setNotes('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||
<h2 className="text-xl font-semibold text-white">Agregar Gasto / Deuda</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex p-1 mx-6 mt-6 bg-slate-800/50 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab('variable')}
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200",
|
||||
activeTab === 'variable' ? "bg-cyan-500 text-white shadow-lg" : "text-slate-400 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Variable (Único)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('fixed')}
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-md transition-all duration-200",
|
||||
activeTab === 'fixed' ? "bg-cyan-500 text-white shadow-lg" : "text-slate-400 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Fijo (Recurrente)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
|
||||
{/* Amount Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white text-lg font-mono outline-none transition-all placeholder:text-slate-600"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Descripción</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: Supermercado Coto, Netflix, Alquiler"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none transition-all placeholder:text-slate-600"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
{/* Category Select */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
|
||||
<Tag size={12} /> Categoría
|
||||
</label>
|
||||
<select
|
||||
value={activeTab === 'fixed' ? categoryFixed : categoryVariable}
|
||||
onChange={(e) => activeTab === 'fixed' ? setCategoryFixed(e.target.value) : setCategoryVariable(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none appearance-none cursor-pointer"
|
||||
>
|
||||
{activeTab === 'fixed' ? (
|
||||
<>
|
||||
<option value="housing">Vivienda</option>
|
||||
<option value="services">Servicios</option>
|
||||
<option value="subscription">Suscripciones</option>
|
||||
<option value="other">Otro</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="food">Comida / Super</option>
|
||||
<option value="shopping">Compras</option>
|
||||
<option value="transport">Transporte</option>
|
||||
<option value="health">Salud</option>
|
||||
<option value="entertainment">Entretenimiento</option>
|
||||
<option value="other">Otro</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date/DueDay Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
|
||||
<Calendar size={12} /> {activeTab === 'fixed' ? 'Día Vencimiento' : 'Fecha'}
|
||||
</label>
|
||||
{activeTab === 'fixed' ? (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={dueDay}
|
||||
onChange={(e) => setDueDay(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
|
||||
required
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 text-sm">del mes</span>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="date"
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'fixed' && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-slate-800/30 rounded-lg cursor-pointer" onClick={() => setIsAutoDebit(!isAutoDebit)}>
|
||||
<div className={cn("w-5 h-5 rounded border flex items-center justify-center transition-colors", isAutoDebit ? "bg-cyan-500 border-cyan-500" : "border-slate-600 bg-transparent")}>
|
||||
{isAutoDebit && <CheckCircle2 size={14} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-slate-300 select-none">Débito Automático</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
|
||||
<FileText size={12} /> Notas (Opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Detalles adicionales..."
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none min-h-[80px] text-sm resize-none placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Agregar {activeTab === 'fixed' ? 'Gasto Fijo' : 'Gasto'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
203
components/modals/AddPaymentModal.tsx
Normal file
203
components/modals/AddPaymentModal.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFinanzasStore } from '@/lib/store'
|
||||
import { X, CreditCard, DollarSign, Calendar, FileText, Layers } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AddPaymentModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AddPaymentModal({ isOpen, onClose }: AddPaymentModalProps) {
|
||||
const cards = useFinanzasStore((state) => state.creditCards)
|
||||
const addCardPayment = useFinanzasStore((state) => state.addCardPayment)
|
||||
|
||||
const [selectedCardId, setSelectedCardId] = useState(cards[0]?.id || '')
|
||||
const [description, setDescription] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [dateStr, setDateStr] = useState(new Date().toISOString().split('T')[0])
|
||||
const [hasInstallments, setHasInstallments] = useState(false)
|
||||
const [installments, setInstallments] = useState('1')
|
||||
const [totalInstallments, setTotalInstallments] = useState('12')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Ensure card selection if cards exist
|
||||
if (!selectedCardId && cards.length > 0) {
|
||||
setSelectedCardId(cards[0].id)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!description || !amount || !selectedCardId) return
|
||||
|
||||
addCardPayment({
|
||||
cardId: selectedCardId,
|
||||
amount: parseFloat(amount),
|
||||
date: new Date(dateStr).toISOString(),
|
||||
description,
|
||||
installments: hasInstallments ? {
|
||||
current: parseInt(installments),
|
||||
total: parseInt(totalInstallments)
|
||||
} : undefined
|
||||
})
|
||||
|
||||
// Reset
|
||||
setDescription('')
|
||||
setAmount('')
|
||||
setHasInstallments(false)
|
||||
setInstallments('1')
|
||||
setTotalInstallments('12')
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg rounded-xl bg-slate-900 border border-slate-800 shadow-2xl overflow-hidden scale-100 animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
||||
<h2 className="text-xl font-semibold text-white">Registrar Consumo / Pago</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<div className="p-8 text-center space-y-4">
|
||||
<CreditCard className="mx-auto text-slate-600 mb-2" size={48} />
|
||||
<h3 className="text-lg font-medium text-white">No tienes tarjetas registradas</h3>
|
||||
<p className="text-slate-400">Debes agregar una tarjeta antes de registrar pagos.</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg transition"
|
||||
>
|
||||
Entendido
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
|
||||
{/* Card Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Tarjeta</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-[120px] overflow-y-auto pr-1">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
onClick={() => setSelectedCardId(card.id)}
|
||||
className={cn(
|
||||
"cursor-pointer p-3 rounded-lg border flex items-center gap-3 transition-all",
|
||||
selectedCardId === card.id
|
||||
? "border-cyan-500 bg-cyan-500/10 ring-1 ring-cyan-500"
|
||||
: "border-slate-800 bg-slate-950 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: card.color }} />
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="text-sm font-medium text-white truncate">{card.name}</span>
|
||||
<span className="text-xs text-slate-500">**** {card.lastFourDigits}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Monto</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-semibold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-3 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white text-lg font-mono outline-none"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Descripción</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: Cena McDonalds, Compra ML"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider flex items-center gap-1">
|
||||
<Calendar size={12} /> Fecha
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 text-white outline-none [color-scheme:dark]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Installments Toggle */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-slate-800/30 rounded-lg cursor-pointer" onClick={() => setHasInstallments(!hasInstallments)}>
|
||||
<div className={cn("w-5 h-5 rounded border flex items-center justify-center transition-colors", hasInstallments ? "bg-cyan-500 border-cyan-500" : "border-slate-600 bg-transparent")}>
|
||||
{hasInstallments && <Layers size={14} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-slate-300 select-none">Es una compra en cuotas</span>
|
||||
</div>
|
||||
|
||||
{/* Installments Inputs */}
|
||||
{hasInstallments && (
|
||||
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Cuota N°</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={installments}
|
||||
onChange={(e) => setInstallments(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Total Cuotas</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={totalInstallments}
|
||||
onChange={(e) => setTotalInstallments(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-white font-semibold rounded-lg shadow-lg shadow-cyan-500/20 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Registrar Pago
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
359
lib/alerts.ts
Normal file
359
lib/alerts.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
FixedDebt,
|
||||
VariableDebt,
|
||||
CreditCard,
|
||||
MonthlyBudget,
|
||||
Alert,
|
||||
} from './types'
|
||||
import {
|
||||
getDaysUntil,
|
||||
getNextDateByDay,
|
||||
formatCurrency,
|
||||
calculateTotalFixedDebts,
|
||||
calculateTotalVariableDebts,
|
||||
} from './utils'
|
||||
|
||||
export interface GenerateAlertsParams {
|
||||
fixedDebts: FixedDebt[]
|
||||
variableDebts: VariableDebt[]
|
||||
creditCards: CreditCard[]
|
||||
monthlyBudgets: MonthlyBudget[]
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
}
|
||||
|
||||
interface AlertDraft {
|
||||
type: Alert['type']
|
||||
title: string
|
||||
message: string
|
||||
severity: Alert['severity']
|
||||
relatedId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene las deudas fijas no pagadas que vencen en los próximos N días
|
||||
*/
|
||||
export function getUpcomingFixedDebts(
|
||||
fixedDebts: FixedDebt[],
|
||||
days: number
|
||||
): Array<{ debt: FixedDebt; daysUntil: number; dueDate: Date }> {
|
||||
const today = new Date()
|
||||
const currentDay = today.getDate()
|
||||
const currentMonth = today.getMonth()
|
||||
const currentYear = today.getFullYear()
|
||||
|
||||
return fixedDebts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.map((debt) => {
|
||||
// Calcular la fecha de vencimiento para este mes
|
||||
let dueDate = new Date(currentYear, currentMonth, debt.dueDay)
|
||||
|
||||
// Si ya pasó, calcular para el mes siguiente
|
||||
if (currentDay > debt.dueDay) {
|
||||
dueDate = new Date(currentYear, currentMonth + 1, debt.dueDay)
|
||||
}
|
||||
|
||||
const daysUntil = getDaysUntil(dueDate)
|
||||
|
||||
return { debt, daysUntil, dueDate }
|
||||
})
|
||||
.filter(({ daysUntil }) => daysUntil >= 0 && daysUntil <= days)
|
||||
.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el presupuesto del mes actual
|
||||
*/
|
||||
export function getCurrentMonthBudget(
|
||||
monthlyBudgets: MonthlyBudget[],
|
||||
month: number,
|
||||
year: number
|
||||
): MonthlyBudget | null {
|
||||
return (
|
||||
monthlyBudgets.find(
|
||||
(budget) => budget.month === month && budget.year === year
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el gasto actual del mes (deudas fijas + variables no pagadas)
|
||||
*/
|
||||
export function calculateCurrentSpending(
|
||||
fixedDebts: FixedDebt[],
|
||||
variableDebts: VariableDebt[]
|
||||
): number {
|
||||
const fixedSpending = calculateTotalFixedDebts(fixedDebts)
|
||||
const variableSpending = calculateTotalVariableDebts(variableDebts)
|
||||
|
||||
return fixedSpending + variableSpending
|
||||
}
|
||||
|
||||
interface CardEvent {
|
||||
card: CreditCard
|
||||
type: 'closing' | 'due'
|
||||
daysUntil: number
|
||||
date: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene los eventos próximos de tarjetas (cierre o vencimiento)
|
||||
*/
|
||||
export function getUpcomingCardEvents(
|
||||
creditCards: CreditCard[],
|
||||
days: number
|
||||
): CardEvent[] {
|
||||
const events: CardEvent[] = []
|
||||
|
||||
for (const card of creditCards) {
|
||||
// Calcular próximo cierre
|
||||
const closingDate = getNextDateByDay(card.closingDay)
|
||||
const daysUntilClosing = getDaysUntil(closingDate)
|
||||
|
||||
if (daysUntilClosing >= 0 && daysUntilClosing <= days) {
|
||||
events.push({
|
||||
card,
|
||||
type: 'closing',
|
||||
daysUntil: daysUntilClosing,
|
||||
date: closingDate,
|
||||
})
|
||||
}
|
||||
|
||||
// Calcular próximo vencimiento
|
||||
const dueDate = getNextDateByDay(card.dueDay)
|
||||
const daysUntilDue = getDaysUntil(dueDate)
|
||||
|
||||
if (daysUntilDue >= 0 && daysUntilDue <= days) {
|
||||
events.push({
|
||||
card,
|
||||
type: 'due',
|
||||
daysUntil: daysUntilDue,
|
||||
date: dueDate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar por días hasta el evento
|
||||
return events.sort((a, b) => a.daysUntil - b.daysUntil)
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de pagos próximos (deudas fijas)
|
||||
*/
|
||||
function generatePaymentDueAlerts(fixedDebts: FixedDebt[]): AlertDraft[] {
|
||||
const upcomingDebts = getUpcomingFixedDebts(fixedDebts, 3)
|
||||
const alerts: AlertDraft[] = []
|
||||
|
||||
for (const { debt, daysUntil } of upcomingDebts) {
|
||||
const severity: Alert['severity'] =
|
||||
daysUntil <= 1 ? 'danger' : 'warning'
|
||||
|
||||
const daysText = daysUntil === 0 ? 'hoy' : daysUntil === 1 ? 'mañana' : `en ${daysUntil} días`
|
||||
|
||||
alerts.push({
|
||||
type: 'PAYMENT_DUE',
|
||||
title: 'Pago próximo',
|
||||
message: `'${debt.name}' vence ${daysText}: ${formatCurrency(debt.amount)}`,
|
||||
severity,
|
||||
relatedId: debt.id,
|
||||
})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de presupuesto
|
||||
*/
|
||||
function generateBudgetAlerts(
|
||||
fixedDebts: FixedDebt[],
|
||||
variableDebts: VariableDebt[],
|
||||
monthlyBudgets: MonthlyBudget[],
|
||||
currentMonth: number,
|
||||
currentYear: number
|
||||
): AlertDraft[] {
|
||||
const currentBudget = getCurrentMonthBudget(
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
)
|
||||
|
||||
if (!currentBudget) {
|
||||
return []
|
||||
}
|
||||
|
||||
const totalBudget =
|
||||
currentBudget.fixedExpenses + currentBudget.variableExpenses
|
||||
|
||||
if (totalBudget <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
|
||||
const percentageUsed = (currentSpending / totalBudget) * 100
|
||||
|
||||
if (percentageUsed < 80) {
|
||||
return []
|
||||
}
|
||||
|
||||
const severity: Alert['severity'] =
|
||||
percentageUsed > 95 ? 'danger' : 'warning'
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'BUDGET_WARNING',
|
||||
title: 'Presupuesto al límite',
|
||||
message: `Has usado el ${percentageUsed.toFixed(1)}% de tu presupuesto mensual`,
|
||||
severity,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de eventos de tarjetas (cierre y vencimiento)
|
||||
*/
|
||||
function generateCardAlerts(creditCards: CreditCard[]): AlertDraft[] {
|
||||
const events = getUpcomingCardEvents(creditCards, 3)
|
||||
const closingAlerts: AlertDraft[] = []
|
||||
const dueAlerts: AlertDraft[] = []
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'closing') {
|
||||
const daysText =
|
||||
event.daysUntil === 0
|
||||
? 'hoy'
|
||||
: event.daysUntil === 1
|
||||
? 'mañana'
|
||||
: `en ${event.daysUntil} días`
|
||||
|
||||
closingAlerts.push({
|
||||
type: 'CARD_CLOSING',
|
||||
title: 'Cierre de tarjeta próximo',
|
||||
message: `Tu tarjeta ${event.card.name} cierra ${daysText}. Balance: ${formatCurrency(event.card.currentBalance)}`,
|
||||
severity: 'info',
|
||||
relatedId: event.card.id,
|
||||
})
|
||||
} else {
|
||||
const severity: Alert['severity'] =
|
||||
event.daysUntil <= 2 ? 'warning' : 'info'
|
||||
|
||||
const daysText =
|
||||
event.daysUntil === 0
|
||||
? 'hoy'
|
||||
: event.daysUntil === 1
|
||||
? 'mañana'
|
||||
: `en ${event.daysUntil} días`
|
||||
|
||||
dueAlerts.push({
|
||||
type: 'CARD_DUE',
|
||||
title: 'Vencimiento de tarjeta',
|
||||
message: `Vencimiento de ${event.card.name} ${daysText}. Balance: ${formatCurrency(event.card.currentBalance)}`,
|
||||
severity,
|
||||
relatedId: event.card.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...closingAlerts, ...dueAlerts]
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas de meta de ahorro
|
||||
*/
|
||||
function generateSavingsAlerts(
|
||||
fixedDebts: FixedDebt[],
|
||||
variableDebts: VariableDebt[],
|
||||
monthlyBudgets: MonthlyBudget[],
|
||||
currentMonth: number,
|
||||
currentYear: number
|
||||
): AlertDraft[] {
|
||||
const currentBudget = getCurrentMonthBudget(
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
)
|
||||
|
||||
if (!currentBudget || currentBudget.savingsGoal <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const currentSpending = calculateCurrentSpending(fixedDebts, variableDebts)
|
||||
const projectedSavings = currentBudget.totalIncome - currentSpending
|
||||
|
||||
if (projectedSavings >= currentBudget.savingsGoal) {
|
||||
return []
|
||||
}
|
||||
|
||||
const percentageBelow =
|
||||
((currentBudget.savingsGoal - projectedSavings) / currentBudget.savingsGoal) *
|
||||
100
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'SAVINGS_GOAL',
|
||||
title: 'Meta de ahorro',
|
||||
message: `Vas ${percentageBelow.toFixed(0)}% por debajo de tu meta de ahorro mensual`,
|
||||
severity: 'info',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina alertas duplicadas basándose en tipo y relatedId
|
||||
*/
|
||||
function deduplicateAlerts(alerts: AlertDraft[]): AlertDraft[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
return alerts.filter((alert) => {
|
||||
const key = `${alert.type}-${alert.relatedId || 'global'}`
|
||||
|
||||
if (seen.has(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera todas las alertas inteligentes basadas en el estado actual
|
||||
*/
|
||||
export function generateAlerts(params: GenerateAlertsParams): AlertDraft[] {
|
||||
const {
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
creditCards,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear,
|
||||
} = params
|
||||
|
||||
const allAlerts: AlertDraft[] = [
|
||||
...generatePaymentDueAlerts(fixedDebts),
|
||||
...generateBudgetAlerts(
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
),
|
||||
...generateCardAlerts(creditCards),
|
||||
...generateSavingsAlerts(
|
||||
fixedDebts,
|
||||
variableDebts,
|
||||
monthlyBudgets,
|
||||
currentMonth,
|
||||
currentYear
|
||||
),
|
||||
]
|
||||
|
||||
// Eliminar duplicados y ordenar por severidad (danger > warning > info)
|
||||
const uniqueAlerts = deduplicateAlerts(allAlerts)
|
||||
|
||||
const severityOrder = { danger: 0, warning: 1, info: 2 }
|
||||
|
||||
return uniqueAlerts.sort(
|
||||
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
|
||||
)
|
||||
}
|
||||
27
lib/store.ts
Normal file
27
lib/store.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { AppState } from '@/lib/types'
|
||||
import { createDebtsSlice, DebtsSlice } from './store/slices/debtsSlice'
|
||||
import { createCardsSlice, CardsSlice } from './store/slices/cardsSlice'
|
||||
import { createBudgetSlice, BudgetSlice } from './store/slices/budgetSlice'
|
||||
import { createAlertsSlice, AlertsSlice } from './store/slices/alertsSlice'
|
||||
|
||||
// Combined State Interface
|
||||
// Note: We extend the individual slices to create the full store interface
|
||||
export interface FinanzasState extends DebtsSlice, CardsSlice, BudgetSlice, AlertsSlice { }
|
||||
|
||||
export const useFinanzasStore = create<FinanzasState>()(
|
||||
persist(
|
||||
(...a) => ({
|
||||
...createDebtsSlice(...a),
|
||||
...createCardsSlice(...a),
|
||||
...createBudgetSlice(...a),
|
||||
...createAlertsSlice(...a),
|
||||
}),
|
||||
{
|
||||
name: 'finanzas-storage',
|
||||
// Optional: Filter what gets persisted if needed in the future
|
||||
// partialize: (state) => ({ ... })
|
||||
}
|
||||
)
|
||||
)
|
||||
45
lib/store/slices/alertsSlice.ts
Normal file
45
lib/store/slices/alertsSlice.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Alert } from '@/lib/types'
|
||||
|
||||
export interface AlertsSlice {
|
||||
alerts: Alert[]
|
||||
|
||||
addAlert: (alert: Omit<Alert, 'id' | 'date'>) => void
|
||||
markAlertAsRead: (id: string) => void
|
||||
deleteAlert: (id: string) => void
|
||||
clearAllAlerts: () => void
|
||||
}
|
||||
|
||||
export const createAlertsSlice: StateCreator<AlertsSlice> = (set) => ({
|
||||
alerts: [],
|
||||
|
||||
addAlert: (alert) =>
|
||||
set((state) => ({
|
||||
alerts: [
|
||||
...state.alerts,
|
||||
{
|
||||
...alert,
|
||||
id: uuidv4(),
|
||||
date: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
})),
|
||||
|
||||
markAlertAsRead: (id) =>
|
||||
set((state) => ({
|
||||
alerts: state.alerts.map((a) =>
|
||||
a.id === id ? { ...a, isRead: true } : a
|
||||
),
|
||||
})),
|
||||
|
||||
deleteAlert: (id) =>
|
||||
set((state) => ({
|
||||
alerts: state.alerts.filter((a) => a.id !== id),
|
||||
})),
|
||||
|
||||
clearAllAlerts: () =>
|
||||
set(() => ({
|
||||
alerts: [],
|
||||
})),
|
||||
})
|
||||
39
lib/store/slices/budgetSlice.ts
Normal file
39
lib/store/slices/budgetSlice.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { MonthlyBudget } from '@/lib/types'
|
||||
|
||||
const now = new Date()
|
||||
|
||||
export interface BudgetSlice {
|
||||
monthlyBudgets: MonthlyBudget[]
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
|
||||
setMonthlyBudget: (budget: MonthlyBudget) => void
|
||||
updateMonthlyBudget: (month: number, year: number, updates: Partial<MonthlyBudget>) => void
|
||||
}
|
||||
|
||||
export const createBudgetSlice: StateCreator<BudgetSlice> = (set) => ({
|
||||
monthlyBudgets: [],
|
||||
currentMonth: now.getMonth() + 1,
|
||||
currentYear: now.getFullYear(),
|
||||
|
||||
setMonthlyBudget: (budget) =>
|
||||
set((state) => {
|
||||
const existingIndex = state.monthlyBudgets.findIndex(
|
||||
(b) => b.month === budget.month && b.year === budget.year
|
||||
)
|
||||
if (existingIndex >= 0) {
|
||||
const newBudgets = [...state.monthlyBudgets]
|
||||
newBudgets[existingIndex] = budget
|
||||
return { monthlyBudgets: newBudgets }
|
||||
}
|
||||
return { monthlyBudgets: [...state.monthlyBudgets, budget] }
|
||||
}),
|
||||
|
||||
updateMonthlyBudget: (month, year, updates) =>
|
||||
set((state) => ({
|
||||
monthlyBudgets: state.monthlyBudgets.map((b) =>
|
||||
b.month === month && b.year === year ? { ...b, ...updates } : b
|
||||
),
|
||||
})),
|
||||
})
|
||||
47
lib/store/slices/cardsSlice.ts
Normal file
47
lib/store/slices/cardsSlice.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { CreditCard, CardPayment } from '@/lib/types'
|
||||
|
||||
export interface CardsSlice {
|
||||
creditCards: CreditCard[]
|
||||
cardPayments: CardPayment[]
|
||||
|
||||
addCreditCard: (card: Omit<CreditCard, 'id'>) => void
|
||||
updateCreditCard: (id: string, card: Partial<CreditCard>) => void
|
||||
deleteCreditCard: (id: string) => void
|
||||
|
||||
addCardPayment: (payment: Omit<CardPayment, 'id'>) => void
|
||||
deleteCardPayment: (id: string) => void
|
||||
}
|
||||
|
||||
export const createCardsSlice: StateCreator<CardsSlice> = (set) => ({
|
||||
creditCards: [],
|
||||
cardPayments: [],
|
||||
|
||||
addCreditCard: (card) =>
|
||||
set((state) => ({
|
||||
creditCards: [...state.creditCards, { ...card, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
updateCreditCard: (id, card) =>
|
||||
set((state) => ({
|
||||
creditCards: state.creditCards.map((c) =>
|
||||
c.id === id ? { ...c, ...card } : c
|
||||
),
|
||||
})),
|
||||
|
||||
deleteCreditCard: (id) =>
|
||||
set((state) => ({
|
||||
creditCards: state.creditCards.filter((c) => c.id !== id),
|
||||
})),
|
||||
|
||||
addCardPayment: (payment) =>
|
||||
set((state) => ({
|
||||
cardPayments: [...state.cardPayments, { ...payment, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
deleteCardPayment: (id) =>
|
||||
set((state) => ({
|
||||
cardPayments: state.cardPayments.filter((p) => p.id !== id),
|
||||
})),
|
||||
})
|
||||
73
lib/store/slices/debtsSlice.ts
Normal file
73
lib/store/slices/debtsSlice.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { StateCreator } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { FixedDebt, VariableDebt } from '@/lib/types'
|
||||
|
||||
export interface DebtsSlice {
|
||||
fixedDebts: FixedDebt[]
|
||||
variableDebts: VariableDebt[]
|
||||
|
||||
// Actions Fixed
|
||||
addFixedDebt: (debt: Omit<FixedDebt, 'id'>) => void
|
||||
updateFixedDebt: (id: string, debt: Partial<FixedDebt>) => void
|
||||
deleteFixedDebt: (id: string) => void
|
||||
toggleFixedDebtPaid: (id: string) => void
|
||||
|
||||
// Actions Variable
|
||||
addVariableDebt: (debt: Omit<VariableDebt, 'id'>) => void
|
||||
updateVariableDebt: (id: string, debt: Partial<VariableDebt>) => void
|
||||
deleteVariableDebt: (id: string) => void
|
||||
toggleVariableDebtPaid: (id: string) => void
|
||||
}
|
||||
|
||||
export const createDebtsSlice: StateCreator<DebtsSlice> = (set) => ({
|
||||
fixedDebts: [],
|
||||
variableDebts: [],
|
||||
|
||||
addFixedDebt: (debt) =>
|
||||
set((state) => ({
|
||||
fixedDebts: [...state.fixedDebts, { ...debt, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
updateFixedDebt: (id, debt) =>
|
||||
set((state) => ({
|
||||
fixedDebts: state.fixedDebts.map((d) =>
|
||||
d.id === id ? { ...d, ...debt } : d
|
||||
),
|
||||
})),
|
||||
|
||||
deleteFixedDebt: (id) =>
|
||||
set((state) => ({
|
||||
fixedDebts: state.fixedDebts.filter((d) => d.id !== id),
|
||||
})),
|
||||
|
||||
toggleFixedDebtPaid: (id) =>
|
||||
set((state) => ({
|
||||
fixedDebts: state.fixedDebts.map((d) =>
|
||||
d.id === id ? { ...d, isPaid: !d.isPaid } : d
|
||||
),
|
||||
})),
|
||||
|
||||
addVariableDebt: (debt) =>
|
||||
set((state) => ({
|
||||
variableDebts: [...state.variableDebts, { ...debt, id: uuidv4() }],
|
||||
})),
|
||||
|
||||
updateVariableDebt: (id, debt) =>
|
||||
set((state) => ({
|
||||
variableDebts: state.variableDebts.map((d) =>
|
||||
d.id === id ? { ...d, ...debt } : d
|
||||
),
|
||||
})),
|
||||
|
||||
deleteVariableDebt: (id) =>
|
||||
set((state) => ({
|
||||
variableDebts: state.variableDebts.filter((d) => d.id !== id),
|
||||
})),
|
||||
|
||||
toggleVariableDebtPaid: (id) =>
|
||||
set((state) => ({
|
||||
variableDebts: state.variableDebts.map((d) =>
|
||||
d.id === id ? { ...d, isPaid: !d.isPaid } : d
|
||||
),
|
||||
})),
|
||||
})
|
||||
74
lib/types.ts
Normal file
74
lib/types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export interface FixedDebt {
|
||||
id: string
|
||||
name: string
|
||||
amount: number
|
||||
dueDay: number
|
||||
category: 'housing' | 'services' | 'subscription' | 'other'
|
||||
isAutoDebit: boolean
|
||||
isPaid: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface VariableDebt {
|
||||
id: string
|
||||
name: string
|
||||
amount: number
|
||||
date: string
|
||||
category: 'shopping' | 'food' | 'entertainment' | 'health' | 'transport' | 'other'
|
||||
isPaid: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface CreditCard {
|
||||
id: string
|
||||
name: string
|
||||
lastFourDigits: string
|
||||
closingDay: number
|
||||
dueDay: number
|
||||
currentBalance: number
|
||||
creditLimit: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface CardPayment {
|
||||
id: string
|
||||
cardId: string
|
||||
amount: number
|
||||
date: string
|
||||
description: string
|
||||
installments?: {
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MonthlyBudget {
|
||||
month: number
|
||||
year: number
|
||||
totalIncome: number
|
||||
fixedExpenses: number
|
||||
variableExpenses: number
|
||||
savingsGoal: number
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string
|
||||
type: 'PAYMENT_DUE' | 'BUDGET_WARNING' | 'CARD_CLOSING' | 'CARD_DUE' | 'SAVINGS_GOAL' | 'UNUSUAL_SPENDING'
|
||||
title: string
|
||||
message: string
|
||||
severity: 'info' | 'warning' | 'danger'
|
||||
date: string
|
||||
isRead: boolean
|
||||
relatedId?: string
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
fixedDebts: FixedDebt[]
|
||||
variableDebts: VariableDebt[]
|
||||
creditCards: CreditCard[]
|
||||
cardPayments: CardPayment[]
|
||||
monthlyBudgets: MonthlyBudget[]
|
||||
alerts: Alert[]
|
||||
currentMonth: number
|
||||
currentYear: number
|
||||
}
|
||||
189
lib/utils.ts
Normal file
189
lib/utils.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { FixedDebt, VariableDebt, CardPayment } from './types'
|
||||
|
||||
/**
|
||||
* Combina clases de Tailwind CSS usando clsx y tailwind-merge
|
||||
* Permite combinar múltiples clases condicionalmente
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea un número como moneda (pesos argentinos/USD)
|
||||
* Ejemplo: 1500.50 -> "$ 1.500,50"
|
||||
*/
|
||||
export function formatCurrency(amount: number): string {
|
||||
const formatter = new Intl.NumberFormat('es-AR', {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
return formatter.format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha en formato legible en español
|
||||
* Ejemplo: "28 de enero de 2026"
|
||||
*/
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const formatter = new Intl.DateTimeFormat('es-AR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
return formatter.format(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha en formato corto
|
||||
* Ejemplo: "28/01/2026"
|
||||
*/
|
||||
export function formatShortDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const formatter = new Intl.DateTimeFormat('es-AR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
return formatter.format(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula los días hasta una fecha específica
|
||||
* Retorna un número negativo si la fecha ya pasó
|
||||
*/
|
||||
export function getDaysUntil(date: string | Date): number {
|
||||
const targetDate = typeof date === 'string' ? new Date(date) : date
|
||||
const today = new Date()
|
||||
|
||||
// Reset hours to compare only dates
|
||||
const target = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate())
|
||||
const current = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
|
||||
const diffTime = target.getTime() - current.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return diffDays
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la próxima fecha para un día específico del mes
|
||||
* Si el día ya pasó este mes, devuelve el del mes siguiente
|
||||
*/
|
||||
export function getNextDateByDay(dayOfMonth: number): Date {
|
||||
const today = new Date()
|
||||
const currentYear = today.getFullYear()
|
||||
const currentMonth = today.getMonth()
|
||||
const currentDay = today.getDate()
|
||||
|
||||
let targetYear = currentYear
|
||||
let targetMonth = currentMonth
|
||||
|
||||
// Si el día ya pasó este mes, ir al siguiente mes
|
||||
if (currentDay > dayOfMonth) {
|
||||
targetMonth += 1
|
||||
if (targetMonth > 11) {
|
||||
targetMonth = 0
|
||||
targetYear += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Ajustar si el día no existe en el mes objetivo (ej: 31 de febrero)
|
||||
const lastDayOfMonth = new Date(targetYear, targetMonth + 1, 0).getDate()
|
||||
const targetDay = Math.min(dayOfMonth, lastDayOfMonth)
|
||||
|
||||
return new Date(targetYear, targetMonth, targetDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el nombre del mes en español
|
||||
* El mes debe ser 1-12 (enero = 1)
|
||||
*/
|
||||
export function getMonthName(month: number): string {
|
||||
const monthNames = [
|
||||
'enero',
|
||||
'febrero',
|
||||
'marzo',
|
||||
'abril',
|
||||
'mayo',
|
||||
'junio',
|
||||
'julio',
|
||||
'agosto',
|
||||
'septiembre',
|
||||
'octubre',
|
||||
'noviembre',
|
||||
'diciembre',
|
||||
]
|
||||
|
||||
if (month < 1 || month > 12) {
|
||||
throw new Error('El mes debe estar entre 1 y 12')
|
||||
}
|
||||
|
||||
return monthNames[month - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el total de deudas fijas no pagadas
|
||||
*/
|
||||
export function calculateTotalFixedDebts(debts: FixedDebt[]): number {
|
||||
return debts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.reduce((total, debt) => total + debt.amount, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el total de deudas variables no pagadas
|
||||
*/
|
||||
export function calculateTotalVariableDebts(debts: VariableDebt[]): number {
|
||||
return debts
|
||||
.filter((debt) => !debt.isPaid)
|
||||
.reduce((total, debt) => total + debt.amount, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el total de pagos de tarjeta
|
||||
* Opcionalmente filtrados por cardId
|
||||
*/
|
||||
export function calculateCardPayments(
|
||||
payments: CardPayment[],
|
||||
cardId?: string
|
||||
): number {
|
||||
const filteredPayments = cardId
|
||||
? payments.filter((payment) => payment.cardId === cardId)
|
||||
: payments
|
||||
|
||||
return filteredPayments.reduce((total, payment) => total + payment.amount, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la próxima fecha de cierre de tarjeta
|
||||
* Si el día de cierre ya pasó este mes, devuelve el del mes siguiente
|
||||
*/
|
||||
export function calculateNextClosingDate(closingDay: number): Date {
|
||||
return getNextDateByDay(closingDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la próxima fecha de vencimiento de tarjeta
|
||||
* Si el día de vencimiento ya pasó este mes, devuelve el del mes siguiente
|
||||
*/
|
||||
export function calculateNextDueDate(dueDay: number): Date {
|
||||
return getNextDateByDay(dueDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el porcentaje de utilización de una tarjeta de crédito
|
||||
* Retorna un valor entre 0 y 100
|
||||
*/
|
||||
export function getCardUtilization(balance: number, limit: number): number {
|
||||
if (limit <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const utilization = (balance / limit) * 100
|
||||
return Math.min(Math.max(utilization, 0), 100)
|
||||
}
|
||||
10
next.config.js
Normal file
10
next.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
distDir: 'dist',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
2040
package-lock.json
generated
Normal file
2040
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "finanzas",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "^14.2.35",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
58
tailwind.config.ts
Normal file
58
tailwind.config.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
40
tsconfig.json
Normal file
40
tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"dist/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user