diff --git a/.env.example b/.env.example index 41c72f48..c648437f 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,3 @@ -# https://vercel.com/docs/storage/vercel-postgres -POSTGRES_URL= -NEXTAUTH_URL=http://localhost:3000 -AUTH_SECRET= # https://generate-secret.vercel.app/32 - -# https://authjs.dev/getting-started/providers/github -AUTH_GITHUB_ID= -AUTH_GITHUB_SECRET= \ No newline at end of file +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=your-anon-key-here diff --git a/GITHUB_OAUTH_SETUP.md b/GITHUB_OAUTH_SETUP.md new file mode 100644 index 00000000..ef44c75d --- /dev/null +++ b/GITHUB_OAUTH_SETUP.md @@ -0,0 +1,44 @@ +# Configurar GitHub OAuth + +## Paso 1: Crear OAuth App en GitHub + +1. Ve a https://github.com/settings/developers +2. Click en "New OAuth App" +3. Llena el formulario: + - **Application name**: Control de Gastos (o el nombre que quieras) + - **Homepage URL**: `http://localhost:3000` + - **Authorization callback URL**: `http://localhost:3000/api/auth/callback/github` +4. Click en "Register application" + +## Paso 2: Obtener credenciales + +1. Copia el **Client ID** +2. Click en "Generate a new client secret" +3. Copia el **Client Secret** (solo se muestra una vez) + +## Paso 3: Actualizar .env + +Reemplaza en tu archivo `.env`: + +```bash +GITHUB_ID=tu-client-id-aqui +GITHUB_SECRET=tu-client-secret-aqui +``` + +## Paso 4: Generar NEXTAUTH_SECRET + +```bash +openssl rand -base64 32 +``` + +Copia el resultado y reemplázalo en `.env`: + +```bash +NEXTAUTH_SECRET=el-secreto-generado +``` + +## Paso 5: Reiniciar servidor + +```bash +pnpm dev +``` diff --git a/README.md b/README.md index b00b6efd..69a720cf 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,988 @@ -
Next.js 15 Admin Dashboard Template
-
Built with the Next.js App Router
-
-
-Demo - · -Clone & Deploy - -
+# 💰 Sistema de Gestión de Gastos Personales + +> Aplicación web moderna para gestionar gastos personales con soporte para gastos recurrentes, categorización inteligente y seguimiento de estados de pago. + +[![Version](https://img.shields.io/badge/version-2.0.0-blue.svg)](https://github.com/luishron/nextjs-postgres-nextauth-tailwindcss-template/releases/tag/v2.0.0) +[![Next.js](https://img.shields.io/badge/Next.js-15.1.9-black)](https://nextjs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)](https://www.typescriptlang.org/) +[![Supabase](https://img.shields.io/badge/Supabase-Latest-green)](https://supabase.com/) + +## 📋 Tabla de Contenidos + +- [Características](#-características) +- [Novedades en v2.0.0](#-novedades-en-v200) +- [Stack Tecnológico](#-stack-tecnológico) +- [Arquitectura](#-arquitectura) +- [Instalación](#-instalación) +- [Configuración](#-configuración) +- [Uso](#-uso) +- [Estructura del Proyecto](#-estructura-del-proyecto) +- [Módulos Principales](#-módulos-principales) +- [Documentación de la Base de Datos](#-documentación-de-la-base-de-datos) +- [Roadmap](#-roadmap) + +--- + +## ✨ Características + +### 📊 Dashboard Inteligente + +- **Resumen Mensual**: Vista consolidada de gastos e ingresos del mes actual +- **Comparativa Temporal**: Análisis de mes anterior, actual y proyección del próximo mes +- **KPIs Principales**: Indicadores clave con tendencias y cambios porcentuales +- **Próximos Gastos a Vencer**: Widget con los gastos pendientes ordenados por urgencia +- **Top Categorías**: Gráfico visual de las 5 categorías con mayor gasto +- **Estados Vacíos Inteligentes**: Onboarding guiado para nuevos usuarios sin datos +- **Balance en Tiempo Real**: Cálculo automático de ingresos - gastos + +### 🎯 Gestión de Gastos + +- **CRUD Completo**: Crear, leer, actualizar y eliminar gastos +- **Estados de Pago**: Seguimiento automático (pendiente, pagado, vencido) +- **Detección de Vencimientos**: Marcado automático de gastos vencidos por fecha +- **Métodos de Pago Dinámicos**: Selección de métodos configurados (banco + últimos 4 dígitos) +- **Ordenamiento Inteligente**: Prioriza vencidos → pendientes → pagados +- **Estadísticas en Tiempo Real**: Totales y desglose por estado en la tabla +- **Visual de Urgencia**: Resaltado de gastos vencidos con bordes y colores +- **Notas Personalizadas**: Agregar contexto adicional a cada gasto +- **Filtros Inteligentes**: Por tipo (todos, recurrentes, únicos) + +### 🔄 Gastos Recurrentes Avanzados + +- **Generación Virtual**: Cálculo automático de próximas instancias sin saturar la BD +- **Mensajes Inteligentes**: "Vence en X días/semanas/meses" +- **Pago Anticipado**: Posibilidad de pagar instancias futuras +- **Filtrado Automático**: Oculta instancias ya pagadas +- **Frecuencias Soportadas**: Semanal, mensual, anual +- **Vista Dedicada**: Pestaña especializada para gastos recurrentes + +### 🏷️ Categorías Personalizables + +- **CRUD Completo**: Gestión total de categorías +- **Personalización Visual**: Colores e iconos emoji +- **Totales Automáticos**: Cálculo en tiempo real del gasto por categoría +- **Descripción**: Contexto adicional para cada categoría +- **Cards Visuales**: Presentación clara con totales destacados + +### 💳 Métodos de Pago + +- **CRUD Completo**: Crear, editar y eliminar métodos de pago +- **Tipos Flexibles**: Tarjeta de crédito/débito, efectivo, transferencia, otro +- **Información Bancaria**: Asociar banco y últimos 4 dígitos de tarjeta +- **Método Predeterminado**: Marca un método como predeterminado para selección automática +- **Personalización Visual**: Colores personalizables para cada método +- **Iconos Dinámicos**: Iconos automáticos según el tipo de método +- **Integración Completa**: Selección de métodos al crear/editar gastos +- **Display Inteligente**: Muestra "Nombre (Banco) ••1234" en formularios y tablas + +### 💰 Gestión de Ingresos + +- **CRUD Completo**: Crear, leer, actualizar y eliminar ingresos +- **Categorías Separadas**: Sistema de categorías independiente para ingresos +- **Ingresos Recurrentes**: Seguimiento de salarios y otros ingresos periódicos +- **Frecuencias**: Semanal, mensual, anual +- **Métodos de Pago**: Asociar cómo se recibió cada ingreso +- **Vistas Organizadas**: Pestañas para todos, recurrentes y únicos +- **Integración Dashboard**: Ingresos reflejados en KPIs y balance +- **Categorías Predefinidas**: Salario, Freelance, Inversiones, Otros + +### 🎨 Interfaz de Usuario + +- **Diseño Moderno**: UI basada en shadcn/ui +- **Responsive**: Adaptable a móvil, tablet y desktop +- **Badges Semánticos**: Colores según urgencia y estado +- **Tablas Interactivas**: Acciones contextuales (editar, eliminar) +- **Diálogos Modales**: Experiencia fluida sin cambios de página +- **Formato MXN**: Moneda mexicana con separadores correctos + +--- + +## 🚀 Novedades en v2.0.0 + +### Dashboard Inteligente Completamente Renovado + +El dashboard ahora ofrece una vista completa de tu situación financiera: + +**KPIs Principales:** +- Gastos del mes con tendencia vs mes anterior (↑ / ↓ %) +- Ingresos del mes con tendencia +- Balance en tiempo real (verde si positivo, rojo si negativo) +- Gastos vencidos destacados + +**Comparativa Temporal:** +- Vista de 3 meses: anterior, actual y proyección del próximo +- Proyección automática basada en gastos recurrentes +- Detección inteligente de nuevos usuarios con onboarding + +**Widgets Analíticos:** +- Próximos 7 gastos a vencer con badges de urgencia +- Top 5 categorías del mes con porcentajes y barras visuales +- Contador inteligente (hoy, mañana, en X días/semanas) + +### Sistema de Ingresos Completo + +**Funcionalidades:** +- CRUD completo de ingresos (crear, editar, eliminar) +- Categorías separadas e independientes de gastos +- 4 categorías predefinidas: Salario, Freelance, Inversiones, Otros +- Ingresos recurrentes (semanal, mensual, anual) +- Vistas organizadas en pestañas (todos, recurrentes, únicos) +- Integración completa con el dashboard para cálculo de balance -## Overview +**Migración Automática:** +- Script SQL que crea la estructura completa +- Asignación inteligente de categorías basada en palabras clave +- Triggers automáticos para `updated_at` -This is a starter template using the following stack: +### Tabla de Gastos Mejorada (UX/UI) -- Framework - [Next.js (App Router)](https://nextjs.org) -- Language - [TypeScript](https://www.typescriptlang.org) -- Auth - [Auth.js](https://authjs.dev) -- Database - [Postgres](https://vercel.com/postgres) -- Deployment - [Vercel](https://vercel.com/docs/concepts/next.js/overview) -- Styling - [Tailwind CSS](https://tailwindcss.com) -- Components - [Shadcn UI](https://ui.shadcn.com/) -- Analytics - [Vercel Analytics](https://vercel.com/analytics) -- Formatting - [Prettier](https://prettier.io) +**Ordenamiento Inteligente:** +- Prioridad automática: vencidos → pendientes → pagados +- Dentro de cada grupo, ordenado por fecha +- Resaltado visual de gastos vencidos (fondo rojo, borde izquierdo) -This template uses the new Next.js App Router. This includes support for enhanced layouts, colocation of components, tests, and styles, component-level data fetching, and more. +**Estadísticas en Tiempo Real:** +- Total general al pie de la tabla +- Desglose detallado por estado en cards visuales +- Contador de gastos por cada estado +- Totales calculados automáticamente -## Getting Started +**Mejoras Visuales:** +- Badges de estado con colores semánticos (verde/amarillo/rojo) +- Display mejorado de métodos de pago (banco + últimos 4 dígitos) +- Cards de resumen con iconos y colores distintivos -During the deployment, Vercel will prompt you to create a new Postgres database. This will add the necessary environment variables to your project. +### Integración de Métodos de Pago Dinámicos -Inside the Vercel Postgres dashboard, create a table based on the schema defined in this repository. +- Eliminados valores hardcodeados +- Selección desde tabla `payment_methods` +- Display inteligente: "Nombre (Banco) ••1234" +- Fallback para valores legacy +--- + +## 🛠 Stack Tecnológico + +### Frontend +- **[Next.js 15](https://nextjs.org/)** - Framework React con App Router +- **[TypeScript](https://www.typescriptlang.org/)** - Tipado estático +- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS +- **[shadcn/ui](https://ui.shadcn.com/)** - Componentes accesibles y personalizables +- **[Lucide React](https://lucide.dev/)** - Iconos modernos + +### Backend & Base de Datos +- **[Supabase](https://supabase.com/)** - Base de datos PostgreSQL + Auth +- **[Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations)** - Mutaciones del lado del servidor +- **[Auth.js (NextAuth)](https://authjs.dev/)** - Autenticación con GitHub OAuth + +### Desarrollo +- **[pnpm](https://pnpm.io/)** - Package manager eficiente +- **[ESLint](https://eslint.org/)** - Linting de código +- **[Prettier](https://prettier.io/)** - Formateo de código + +--- + +## 🏗 Arquitectura + +### Patrón de Diseño + +El proyecto sigue una arquitectura **Server-First** con Next.js App Router: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Cliente (Browser) │ +│ - React Components (Client Components) │ +│ - UI State Management │ +│ - Optimistic Updates │ +└────────────────┬────────────────────────────────────────┘ + │ + ↓ Server Actions / API Routes +┌─────────────────────────────────────────────────────────┐ +│ Servidor (Next.js) │ +│ - Server Components (RSC) │ +│ - Server Actions (Mutations) │ +│ - Authentication Middleware │ +│ - Business Logic │ +└────────────────┬────────────────────────────────────────┘ + │ + ↓ Supabase Client +┌─────────────────────────────────────────────────────────┐ +│ Base de Datos (Supabase) │ +│ - PostgreSQL Database │ +│ - Row Level Security (RLS) │ +│ - Real-time Subscriptions │ +└─────────────────────────────────────────────────────────┘ +``` + +### Flujo de Datos + +1. **Server Components** → Fetch inicial de datos en el servidor +2. **Client Components** → Interacción del usuario +3. **Server Actions** → Mutaciones seguras en el servidor +4. **Supabase Client** → Operaciones de base de datos +5. **Revalidation** → Actualización automática de la UI + +--- + +## 📦 Instalación + +### Prerequisitos + +- Node.js 18.17 o superior +- pnpm 8.0 o superior +- Cuenta de Supabase +- Cuenta de GitHub (para OAuth) + +### Pasos + +1. **Clonar el repositorio** + +```bash +git clone https://github.com/luishron/nextjs-postgres-nextauth-tailwindcss-template.git +cd nextjs-postgres-nextauth-tailwindcss-template +``` + +2. **Instalar dependencias** + +```bash +pnpm install +``` + +3. **Configurar variables de entorno** + +```bash +cp .env.example .env ``` -CREATE TYPE status AS ENUM ('active', 'inactive', 'archived'); -CREATE TABLE products ( +Edita `.env` y configura: +- Credenciales de Supabase +- GitHub OAuth credentials +- NextAuth secret + +4. **Configurar Supabase** + +Ejecuta el script SQL en Supabase SQL Editor: + +```bash +# Ver archivo: supabase-init.sql +``` + +5. **Agregar estados de pago** (Requerido para v2.0.0) + +```bash +# Ver archivo: supabase-add-payment-status.sql +``` + +6. **Agregar sistema de ingresos** (Requerido para v2.0.0) + +```bash +# Ver archivo: supabase-incomes-migration.sql +``` + +7. **Iniciar servidor de desarrollo** + +```bash +pnpm dev +``` + +Visita [http://localhost:3000](http://localhost:3000) + +--- + +## ⚙️ Configuración + +### Supabase + +Ver documentación detallada en [SUPABASE_SETUP.md](./SUPABASE_SETUP.md) + +### GitHub OAuth + +Ver documentación detallada en [GITHUB_OAUTH_SETUP.md](./GITHUB_OAUTH_SETUP.md) + +### Variables de Entorno + +```env +# Supabase +POSTGRES_URL= +POSTGRES_PRISMA_URL= +POSTGRES_URL_NO_SSL= +POSTGRES_URL_NON_POOLING= +POSTGRES_USER= +POSTGRES_HOST= +POSTGRES_PASSWORD= +POSTGRES_DATABASE= + +# NextAuth +AUTH_SECRET= +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= +``` + +--- + +## 🎮 Uso + +### Crear un Gasto + +1. Navega a la pestaña "Gastos" +2. Click en "Agregar Gasto" +3. Completa el formulario: + - Descripción + - Monto + - Fecha + - Categoría + - Método de pago (selecciona de tus métodos configurados) + - Estado de pago + - Tipo (único o recurrente) +4. Si es recurrente, selecciona la frecuencia +5. Guarda + +### Gestionar Gastos Recurrentes + +1. Crea un gasto marcándolo como "Recurrente" +2. Ve a la pestaña "Recurrentes" +3. Visualiza las próximas instancias en la sección "Próximos Gastos Recurrentes" +4. Click en "Pagar" para registrar el pago de una instancia +5. La instancia desaparece de "Próximos" y se registra en el historial + +### Configurar Métodos de Pago + +1. Navega a "Métodos de Pago" +2. Click en "Nuevo Método de Pago" +3. Define: + - Nombre (ej. "Visa Principal") + - Tipo (tarjeta crédito/débito, efectivo, transferencia, otro) + - Banco (opcional) + - Últimos 4 dígitos (opcional, solo para tarjetas) + - Color (selector visual) + - Marcar como predeterminado +4. Los métodos aparecerán en los formularios de gastos + +### Gestionar Ingresos + +1. Navega a "Ingresos" +2. Si no hay categorías, primero crea una categoría de ingresos +3. Click en "Agregar Ingreso" +4. Completa el formulario: + - Fuente del ingreso (ej. "Salario Enero") + - Monto + - Fecha de recepción + - Categoría + - Tipo (único o recurrente) + - Frecuencia (si es recurrente) +5. Visualiza tus ingresos en las pestañas: + - **Todos**: Lista completa + - **Recurrentes**: Solo ingresos periódicos + - **Únicos**: Solo ingresos puntuales +6. Los ingresos se reflejan automáticamente en el dashboard + +### Categorías + +1. Navega a "Categorías" +2. Click en "Agregar Categoría" +3. Define: + - Nombre + - Color (selector visual) + - Icono emoji + - Descripción +4. Visualiza el total gastado en cada categoría + +--- + +## 📁 Estructura del Proyecto + +``` +gastos/ +├── app/ +│ ├── (dashboard)/ # Grupo de rutas del dashboard +│ │ ├── actions.ts # Server Actions globales +│ │ ├── layout.tsx # Layout compartido +│ │ ├── page.tsx # Dashboard principal con resumen +│ │ ├── dashboard-kpis.tsx # KPIs principales +│ │ ├── monthly-comparison-card.tsx # Comparativa mensual +│ │ ├── upcoming-expenses-widget.tsx # Widget gastos próximos +│ │ ├── top-categories-chart.tsx # Gráfico categorías +│ │ ├── categorias/ # Módulo de categorías de gastos +│ │ │ ├── page.tsx +│ │ │ ├── category-card.tsx +│ │ │ └── add-category-dialog.tsx +│ │ ├── metodos-pago/ # Módulo de métodos de pago +│ │ │ ├── page.tsx +│ │ │ ├── payment-method-card.tsx +│ │ │ └── add-payment-method-dialog.tsx +│ │ ├── gastos/ # Módulo de gastos +│ │ │ ├── page.tsx +│ │ │ ├── expenses-table.tsx +│ │ │ ├── add-expense-dialog.tsx +│ │ │ ├── edit-expense-dialog.tsx +│ │ │ └── upcoming-expenses-card.tsx +│ │ └── ingresos/ # Módulo de ingresos +│ │ ├── page.tsx +│ │ └── categorias/ # Categorías de ingresos +│ │ └── page.tsx +│ ├── login/ # Autenticación +│ └── layout.tsx # Layout raíz +├── components/ +│ └── ui/ # Componentes shadcn/ui +├── lib/ +│ ├── auth.ts # Configuración de Auth.js +│ ├── db.ts # Cliente Supabase + Queries +│ └── supabase/ +│ └── server.ts # Cliente servidor de Supabase +├── types/ # Tipos TypeScript compartidos +├── public/ # Assets estáticos +├── supabase-init.sql # Script de inicialización +├── supabase-add-payment-status.sql # Script estados de pago +├── supabase-payment-methods.sql # Script métodos de pago +├── supabase-incomes-migration.sql # Script sistema de ingresos +└── package.json +``` + +--- + +## 🧩 Módulos Principales + +### 1. Autenticación (`lib/auth.ts`) + +Gestiona la autenticación de usuarios con GitHub OAuth: + +```typescript +export async function getUser(): Promise +``` + +**Características:** +- GitHub OAuth integration +- Session management +- Protected routes + +--- + +### 2. Base de Datos (`lib/db.ts`) + +Cliente central de Supabase con todas las queries: + +#### Tipos Principales + +```typescript +export type Category = { + id: number; + user_id: string; + name: string; + color: string; + icon?: string | null; + description?: string | null; +} + +export type PaymentStatus = 'pagado' | 'pendiente' | 'vencido'; + +export type PaymentMethodType = + | 'tarjeta_credito' + | 'tarjeta_debito' + | 'efectivo' + | 'transferencia' + | 'otro'; + +export type PaymentMethod = { + id: number; + user_id: string; + name: string; + type: PaymentMethodType; + bank?: string | null; + last_four_digits?: string | null; + icon?: string | null; + color: string; + is_default: boolean; +} + +export type Expense = { + id: number; + user_id: string; + category_id: number; + amount: string; + description?: string | null; + date: string; + payment_method?: string; + payment_status?: PaymentStatus; + notes?: string | null; + is_recurring?: number; + recurrence_frequency?: string | null; +} + +export type UpcomingExpense = Expense & { + isVirtual: true; + daysUntilDue: number; + dueMessage: string; + nextDate: string; + templateId: number; +} + +export type IncomeCategory = { + id: number; + user_id: string; + name: string; + color: string; + icon?: string | null; + description?: string | null; +} + +export type Income = { + id: number; + user_id: string; + source: string; + amount: string; + date: string; + description?: string | null; + category_id?: number | null; + payment_method?: string | null; + is_recurring?: number; + recurrence_frequency?: string | null; + notes?: string | null; +} +``` + +#### Funciones de Gastos + +```typescript +// Obtener gastos con paginación y filtros +getExpensesByUser(userId: string, options?: { + search?: string; + isRecurring?: boolean; + offset?: number; + limit?: number; +}): Promise<{ + expenses: Expense[]; + newOffset: number | null; + totalExpenses: number; +}> + +// CRUD de gastos +createExpense(expense: InsertExpense): Promise +updateExpense(id: number, expense: Partial): Promise +deleteExpenseById(id: number): Promise +``` + +#### Funciones de Categorías + +```typescript +// Obtener categorías del usuario +getCategoriesByUser(userId: string): Promise + +// Calcular total gastado por categoría +getCategoryTotalExpenses(userId: string, categoryId: number): Promise + +// CRUD de categorías +createCategory(category: InsertCategory): Promise +updateCategory(id: number, category: Partial): Promise +deleteCategoryById(id: number): Promise +``` + +#### Funciones de Métodos de Pago + +```typescript +// Obtener métodos de pago del usuario (ordenados por predeterminado) +getPaymentMethodsByUser(userId: string): Promise + +// CRUD de métodos de pago +createPaymentMethod(paymentMethod: InsertPaymentMethod): Promise +updatePaymentMethod(id: number, paymentMethod: Partial): Promise +deletePaymentMethodById(id: number): Promise +``` + +**Lógica Especial:** +- Al marcar un método como predeterminado, automáticamente desmarca todos los demás del usuario +- Los métodos se ordenan por predeterminado primero, luego por fecha de creación + +#### Funciones de Gastos Recurrentes + +```typescript +// Generar próximas instancias virtuales +getUpcomingRecurringExpenses( + userId: string, + monthsAhead?: number +): Promise +``` + +**Algoritmo de Generación Virtual:** + +1. Obtiene templates recurrentes de la BD +2. Calcula próximas fechas según frecuencia +3. Genera mensajes inteligentes de vencimiento +4. Filtra instancias ya pagadas +5. Ordena por proximidad + +#### Funciones de Dashboard + +```typescript +// Resumen mensual de ingresos y gastos +getMonthlySummary( + userId: string, + year: number, + month: number +): Promise + +// Obtener gastos vencidos +getOverdueExpenses(userId: string): Promise + +// Próximos gastos a vencer +getUpcomingDueExpenses( + userId: string, + limit?: number +): Promise + +// Top categorías del mes +getTopCategoriesByMonth( + userId: string, + year: number, + month: number, + limit?: number +): Promise + +// Proyección del próximo mes (basado en recurrentes) +getNextMonthProjection(userId: string): Promise +``` + +#### Funciones de Ingresos + +```typescript +// Obtener ingresos del usuario +getIncomesByUser(userId: string): Promise + +// CRUD de ingresos +createIncome(income: InsertIncome): Promise +updateIncome(id: number, income: Partial): Promise +deleteIncomeById(id: number): Promise + +// Categorías de ingresos +getIncomeCategoriesByUser(userId: string): Promise +createIncomeCategory(category: InsertIncomeCategory): Promise +updateIncomeCategory(id: number, category: Partial): Promise +deleteIncomeCategoryById(id: number): Promise +``` + +--- + +### 3. Server Actions (`app/(dashboard)/actions.ts`) + +Mutaciones seguras del lado del servidor: + +```typescript +// Gastos +export async function saveExpense(formData: FormData): Promise +export async function updateExpense(formData: FormData): Promise +export async function deleteExpense(formData: FormData): Promise + +// Categorías +export async function saveCategory(formData: FormData): Promise +export async function updateCategory(formData: FormData): Promise +export async function deleteCategory(formData: FormData): Promise + +// Métodos de Pago +export async function savePaymentMethod(formData: FormData): Promise +export async function updatePaymentMethod(formData: FormData): Promise +export async function deletePaymentMethod(formData: FormData): Promise + +// Gastos Recurrentes +export async function payRecurringExpense(formData: FormData): Promise + +// Ingresos +export async function saveIncome(formData: FormData): Promise +export async function updateIncome(formData: FormData): Promise +export async function deleteIncome(formData: FormData): Promise + +// Categorías de Ingresos +export async function saveIncomeCategory(formData: FormData): Promise +export async function updateIncomeCategory(formData: FormData): Promise +export async function deleteIncomeCategory(formData: FormData): Promise +``` + +**Características:** +- Validación de autenticación +- Manejo de errores centralizado +- Revalidación automática de paths +- Type-safe con FormData + +--- + +### 4. Componentes de UI + +#### ExpensesTable (`expenses-table.tsx`) + +Tabla interactiva de gastos con: +- **Ordenamiento Inteligente**: Prioriza vencidos → pendientes → pagados +- **Estadísticas en Tiempo Real**: Calcula totales por estado +- **Resaltado Visual**: Gastos vencidos con fondo rojo y borde izquierdo +- **Badges Semánticos**: Colores según estado (verde/amarillo/rojo) +- **Desglose Detallado**: Cards al final con totales por estado +- **Formateo de Moneda MXN** +- **Métodos de Pago Dinámicos**: Muestra nombre + banco + últimos dígitos +- **Acciones Contextuales**: Editar y eliminar + +#### DashboardKPIs (`dashboard-kpis.tsx`) + +KPIs principales del mes actual: +- Gastos del mes con tendencia vs mes anterior +- Ingresos del mes con tendencia +- Balance (ingresos - gastos) con indicador visual +- Gastos vencidos destacados en rojo + +#### MonthlyComparisonCard (`monthly-comparison-card.tsx`) + +Comparativa de 3 meses: +- Mes anterior (histórico) +- Mes actual (destacado) +- Próximo mes (proyección basada en recurrentes) +- Manejo inteligente de estados vacíos + +#### UpcomingExpensesWidget (`upcoming-expenses-widget.tsx`) + +Widget de próximos gastos a vencer: +- Muestra próximos 7 gastos pendientes +- Badges de urgencia por color (hoy/mañana/días/semanas) +- Contador de días hasta vencimiento +- Display de categorías y montos + +#### TopCategoriesChart (`top-categories-chart.tsx`) + +Top 5 categorías del mes: +- Ranking visual (#1, #2, etc.) +- Barras de progreso con colores de categoría +- Porcentajes calculados automáticamente +- Total y cantidad de gastos por categoría + +#### UpcomingExpensesCard (`upcoming-expenses-card.tsx`) + +Card de próximos gastos recurrentes: +- Lista de instancias virtuales +- Mensajes de vencimiento dinámicos +- Botón de pago anticipado +- Badges de urgencia por color + +#### CategoryCard (`category-card.tsx`) + +Card visual de categoría: +- Icono emoji personalizado +- Color de fondo configurable +- Total gastado destacado +- Acciones de eliminación + +--- + +## 🗄️ Documentación de la Base de Datos + +### Esquema de Tablas + +#### `users` +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + avatar_url TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +#### `categories` +```sql +CREATE TABLE categories ( id SERIAL PRIMARY KEY, - image_url TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, - status status NOT NULL, - price NUMERIC(10, 2) NOT NULL, - stock INTEGER NOT NULL, - available_at TIMESTAMP NOT NULL + color TEXT NOT NULL DEFAULT '#6366f1', + icon TEXT, + description TEXT, + created_at TIMESTAMP DEFAULT NOW() ); ``` -Then, uncomment `app/api/seed.ts` and hit `http://localhost:3000/api/seed` to seed the database with products. +#### `expenses` +```sql +CREATE TABLE expenses ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + amount NUMERIC(10, 2) NOT NULL, + description TEXT, + date DATE NOT NULL, + payment_method TEXT, + payment_status TEXT DEFAULT 'pendiente' + CHECK (payment_status IN ('pagado', 'pendiente', 'vencido')), + notes TEXT, + is_recurring INTEGER DEFAULT 0, + recurrence_frequency TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); -Next, copy the `.env.example` file to `.env` and update the values. Follow the instructions in the `.env.example` file to set up your GitHub OAuth application. +CREATE INDEX idx_expenses_user_id ON expenses(user_id); +CREATE INDEX idx_expenses_category_id ON expenses(category_id); +CREATE INDEX idx_expenses_date ON expenses(date); +CREATE INDEX idx_expenses_payment_status ON expenses(payment_status); +``` -```bash -npm i -g vercel -vercel link -vercel env pull +#### `payment_methods` +```sql +CREATE TABLE payment_methods ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('tarjeta_credito', 'tarjeta_debito', 'efectivo', 'transferencia', 'otro')), + bank TEXT, + last_four_digits TEXT, + icon TEXT, + color TEXT NOT NULL DEFAULT '#6366f1', + is_default BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_payment_methods_user_id ON payment_methods(user_id); ``` -Finally, run the following commands to start the development server: +#### `income_categories` +```sql +CREATE TABLE income_categories ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#10B981', + icon TEXT, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +CREATE INDEX idx_income_categories_user_id ON income_categories(user_id); ``` -pnpm install -pnpm dev + +#### `incomes` +```sql +CREATE TABLE incomes ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL, + source TEXT NOT NULL, + amount NUMERIC(10, 2) NOT NULL, + date DATE NOT NULL, + description TEXT, + category_id INTEGER REFERENCES income_categories(id) ON DELETE SET NULL, + payment_method TEXT, + is_recurring INTEGER DEFAULT 0 CHECK (is_recurring IN (0, 1)), + recurrence_frequency TEXT CHECK (recurrence_frequency IN ('weekly', 'monthly', 'yearly')), + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_incomes_user_id ON incomes(user_id); +CREATE INDEX idx_incomes_recurring ON incomes(user_id, is_recurring) WHERE is_recurring = 1; +``` + +### Relaciones + +``` +users (1) ──< (N) categories +users (1) ──< (N) expenses +users (1) ──< (N) payment_methods +users (1) ──< (N) income_categories +users (1) ──< (N) incomes +categories (1) ──< (N) expenses +income_categories (1) ──< (N) incomes ``` -You should now be able to access the application at http://localhost:3000. +### Row Level Security (RLS) + +Todas las tablas tienen RLS habilitado: + +```sql +-- Users solo pueden ver sus propios datos +CREATE POLICY "Users can view own data" ON expenses + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own data" ON expenses + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own data" ON expenses + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete own data" ON expenses + FOR DELETE USING (auth.uid() = user_id); +``` + +--- + +## 🗺️ Roadmap + +### ✅ v1.0.0 - MVP Base (Completado) +- [x] Sistema de autenticación con GitHub OAuth +- [x] CRUD de gastos +- [x] Categorías personalizables +- [x] Métodos de pago configurables +- [x] Gastos recurrentes con generación virtual +- [x] Estados de pago (pendiente, pagado, vencido) + +### ✅ v2.0.0 - Dashboard e Ingresos (Completado) +- [x] Dashboard inteligente con KPIs +- [x] Resumen mensual (anterior, actual, proyección) +- [x] Widget de próximos gastos a vencer +- [x] Top categorías con gráficos +- [x] Gestión de ingresos con CRUD completo +- [x] Categorías de ingresos separadas +- [x] Ingresos recurrentes +- [x] Cálculo de balance (ingresos - gastos) +- [x] Tabla de gastos mejorada con ordenamiento inteligente +- [x] Estadísticas en tiempo real +- [x] Estados vacíos con onboarding + +### v2.1.0 - Reportes y Exportación +- [ ] Exportación a CSV/Excel de gastos e ingresos +- [ ] Gráficas de tendencias temporales +- [ ] Reporte PDF mensual +- [ ] Análisis de patrones de gasto +- [ ] Comparativa año a año + +### v2.2.0 - Presupuestos +- [ ] Definir presupuesto por categoría +- [ ] Alertas de sobre-gasto +- [ ] Progreso visual del presupuesto +- [ ] Presupuesto mensual global +- [ ] Notificaciones de límites + +### v2.3.0 - Mejoras de Recurrentes +- [ ] Edición de monto por instancia +- [ ] Pausar/reanudar recurrentes +- [ ] Historial de cambios +- [ ] Predicción de gastos futuros +- [ ] Ajuste automático por inflación + +### v3.0.0 - Metas y Ahorro +- [ ] Definir metas de ahorro +- [ ] Tracking de progreso de metas +- [ ] Sugerencias de ahorro basadas en IA +- [ ] Proyecciones financieras avanzadas +- [ ] Análisis de viabilidad de metas + +--- + +## 📄 Licencia + +Este proyecto está bajo la licencia MIT. + +--- + +## 👤 Autor + +**Luis Naranja** + +- GitHub: [@luishron](https://github.com/luishron) + +--- + +## 🙏 Agradecimientos + +- Template base de [Next.js Admin Dashboard](https://github.com/vercel/nextjs-postgres-nextauth-tailwindcss-template) +- Componentes UI de [shadcn/ui](https://ui.shadcn.com/) +- Iconos de [Lucide](https://lucide.dev/) + +--- + +
+ Hecho con ❤️ y Claude Code +
diff --git a/SUPABASE_SETUP.md b/SUPABASE_SETUP.md new file mode 100644 index 00000000..a27410df --- /dev/null +++ b/SUPABASE_SETUP.md @@ -0,0 +1,223 @@ +# 🔧 Configuración de Supabase (Modo Seguro) + +## 🔒 Enfoque de Seguridad + +Esta aplicación usa un enfoque **más seguro** donde: +- ✅ **Cero credenciales expuestas al navegador** +- ✅ **Todas las operaciones pasan por el servidor** +- ✅ **Variables de entorno privadas** (sin `NEXT_PUBLIC_`) + +## Paso 1: Obtener credenciales de Supabase + +1. Ve a [app.supabase.com](https://app.supabase.com) +2. Inicia sesión o crea una cuenta +3. Crea un nuevo proyecto +4. Ve a **Settings → API** +5. Copia los siguientes valores en tu archivo `.env`: + - `SUPABASE_URL` → URL del proyecto (sin `NEXT_PUBLIC_`) + - `SUPABASE_ANON_KEY` → Anon Key (sin `NEXT_PUBLIC_`) + +## Paso 2: Configurar autenticación + +1. Ve a **Authentication → Providers** en Supabase +2. Habilita **Email** como proveedor +3. Configura las opciones de email (puedes usar las predeterminadas para desarrollo) + +## Paso 3: Crear usuario administrador + +1. Ve a **Authentication → Users** en Supabase +2. Haz clic en **Add user** → **Create new user** +3. Ingresa tu email y contraseña +4. Confirma el usuario (o usa el link de confirmación del email) + +## Paso 4: Crear las tablas + +Ve a **SQL Editor** en Supabase y ejecuta el siguiente SQL: + +```sql +-- Crear tabla de categorías +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#3B82F6', + icon TEXT, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, name) +); + +-- Crear tabla de gastos +CREATE TABLE expenses ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + amount NUMERIC(10, 2) NOT NULL, + description TEXT, + date DATE NOT NULL, + payment_method TEXT DEFAULT 'efectivo', + notes TEXT, + is_recurring INTEGER DEFAULT 0, -- 0 = único, 1 = recurrente + recurrence_frequency TEXT, -- 'monthly', 'weekly', 'yearly', null + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Crear tabla de presupuestos +CREATE TABLE budgets ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + amount NUMERIC(10, 2) NOT NULL, + month INTEGER NOT NULL, + year INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, category_id, month, year) +); + +-- Crear tabla de ingresos +CREATE TABLE incomes ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + source TEXT NOT NULL, + amount NUMERIC(10, 2) NOT NULL, + date DATE NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Crear tabla de estadísticas +CREATE TABLE statistics ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + month INTEGER NOT NULL, + year INTEGER NOT NULL, + total_expenses NUMERIC(10, 2) DEFAULT 0, + total_income NUMERIC(10, 2) DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, month, year) +); + +-- Crear índices para mejor performance +CREATE INDEX expenses_user_id_idx ON expenses(user_id); +CREATE INDEX expenses_date_idx ON expenses(date); +CREATE INDEX expenses_category_id_idx ON expenses(category_id); +CREATE INDEX categories_user_id_idx ON categories(user_id); +CREATE INDEX budgets_user_id_idx ON budgets(user_id); +CREATE INDEX incomes_user_id_idx ON incomes(user_id); + +-- Habilitar Row Level Security (RLS) +ALTER TABLE categories ENABLE ROW LEVEL SECURITY; +ALTER TABLE expenses ENABLE ROW LEVEL SECURITY; +ALTER TABLE budgets ENABLE ROW LEVEL SECURITY; +ALTER TABLE incomes ENABLE ROW LEVEL SECURITY; +ALTER TABLE statistics ENABLE ROW LEVEL SECURITY; + +-- Crear políticas RLS para categories +CREATE POLICY "Users can view their own categories" ON categories + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own categories" ON categories + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own categories" ON categories + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own categories" ON categories + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para expenses +CREATE POLICY "Users can view their own expenses" ON expenses + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own expenses" ON expenses + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own expenses" ON expenses + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own expenses" ON expenses + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para budgets +CREATE POLICY "Users can view their own budgets" ON budgets + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own budgets" ON budgets + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own budgets" ON budgets + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own budgets" ON budgets + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para incomes +CREATE POLICY "Users can view their own incomes" ON incomes + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own incomes" ON incomes + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own incomes" ON incomes + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their own incomes" ON incomes + FOR DELETE USING (auth.uid() = user_id); + +-- Crear políticas RLS para statistics +CREATE POLICY "Users can view their own statistics" ON statistics + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own statistics" ON statistics + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own statistics" ON statistics + FOR UPDATE USING (auth.uid() = user_id); +``` + +## Paso 5: Instalar dependencias + +```bash +pnpm install +``` + +## Paso 6: Ejecutar la app + +```bash +pnpm dev +``` + +La app estará disponible en `http://localhost:3000` + +## 📝 Variables de entorno requeridas + +**IMPORTANTE:** No uses `NEXT_PUBLIC_` para mantener las credenciales seguras en el servidor. + +```env +# Variables privadas (SOLO servidor) +SUPABASE_URL=https://tu-proyecto.supabase.co +SUPABASE_ANON_KEY=tu-anon-key-aqui + +# Variables públicas opcionales +NEXT_PUBLIC_ANALYTICS_ID=opcional +``` + +## ✅ Verificar que todo funciona + +1. Abre [localhost:3000](http://localhost:3000) +2. Inicia sesión con el usuario que creaste en Supabase +3. Crea una categoría +4. Registra un gasto +5. Verifica los datos en Supabase + +## 🔒 Ventajas de este enfoque de seguridad + +- **Cero exposición de credenciales:** Las credenciales NUNCA llegan al navegador +- **Mejor seguridad:** Todo pasa por Server Actions con validaciones del servidor +- **Control total:** Todas las queries se ejecutan en el servidor +- **Compatible con RLS:** Funciona perfectamente con Row Level Security de Supabase +- **Prevención de abuso:** No se pueden hacer llamadas directas a Supabase desde el cliente + +¡Listo! 🎉 diff --git a/app/(dashboard)/actions.ts b/app/(dashboard)/actions.ts deleted file mode 100644 index b16f9056..00000000 --- a/app/(dashboard)/actions.ts +++ /dev/null @@ -1,10 +0,0 @@ -'use server'; - -import { deleteProductById } from '@/lib/db'; -import { revalidatePath } from 'next/cache'; - -export async function deleteProduct(formData: FormData) { - // let id = Number(formData.get('id')); - // await deleteProductById(id); - // revalidatePath('/'); -} diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx deleted file mode 100644 index 80df0482..00000000 --- a/app/(dashboard)/customers/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from '@/components/ui/card'; - -export default function CustomersPage() { - return ( - - - Customers - View all customers and their orders. - - - - ); -} diff --git a/app/(dashboard)/error.tsx b/app/(dashboard)/error.tsx deleted file mode 100644 index 66bcfca3..00000000 --- a/app/(dashboard)/error.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; - -export default function Error({ - error, - reset -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - // Log the error to an error reporting service - console.error(error); - }, [error]); - - return ( -
-
-

- Please complete setup -

-

- Inside the Vercel Postgres dashboard, create a table based on the - schema defined in this repository. -

-
-          
-            {`CREATE TABLE users (
-  id SERIAL PRIMARY KEY,
-  email VARCHAR(255) NOT NULL,
-  name VARCHAR(255),
-  username VARCHAR(255)
-);`}
-          
-        
-

Insert a row for testing:

-
-          
-            {`INSERT INTO users (id, email, name, username) VALUES (1, 'me@site.com', 'Me', 'username');`}
-          
-        
-
-
- ); -} diff --git a/app/(dashboard)/nav-item.tsx b/app/(dashboard)/nav-item.tsx deleted file mode 100644 index 72af5b77..00000000 --- a/app/(dashboard)/nav-item.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; - -import { - Tooltip, - TooltipContent, - TooltipTrigger -} from '@/components/ui/tooltip'; -import clsx from 'clsx'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -export function NavItem({ - href, - label, - children -}: { - href: string; - label: string; - children: React.ReactNode; -}) { - const pathname = usePathname(); - - return ( - - - - {children} - {label} - - - {label} - - ); -} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx deleted file mode 100644 index 89d2160d..00000000 --- a/app/(dashboard)/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { File, PlusCircle } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { ProductsTable } from './products-table'; -import { getProducts } from '@/lib/db'; - -export const dynamic = 'force-dynamic'; - -export default async function ProductsPage( - props: { - searchParams: Promise<{ q: string; offset: string }>; - } -) { - const searchParams = await props.searchParams; - const search = searchParams.q ?? ''; - const offset = searchParams.offset ?? 0; - const { products, newOffset, totalProducts } = await getProducts( - search, - Number(offset) - ); - - return ( - -
- - All - Active - Draft - - Archived - - -
- - -
-
- - - -
- ); -} diff --git a/app/(dashboard)/product.tsx b/app/(dashboard)/product.tsx deleted file mode 100644 index 714fc5e0..00000000 --- a/app/(dashboard)/product.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import Image from 'next/image'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu'; -import { MoreHorizontal } from 'lucide-react'; -import { TableCell, TableRow } from '@/components/ui/table'; -import { SelectProduct } from '@/lib/db'; -import { deleteProduct } from './actions'; - -export function Product({ product }: { product: SelectProduct }) { - return ( - - - Product image - - {product.name} - - - {product.status} - - - {`$${product.price}`} - {product.stock} - - {product.availableAt.toLocaleDateString("en-US")} - - - - - - - - Actions - Edit - -
- -
-
-
-
-
-
- ); -} diff --git a/app/(dashboard)/products-table.tsx b/app/(dashboard)/products-table.tsx deleted file mode 100644 index 0396cec6..00000000 --- a/app/(dashboard)/products-table.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'use client'; - -import { - TableHead, - TableRow, - TableHeader, - TableBody, - Table -} from '@/components/ui/table'; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle -} from '@/components/ui/card'; -import { Product } from './product'; -import { SelectProduct } from '@/lib/db'; -import { useRouter } from 'next/navigation'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -export function ProductsTable({ - products, - offset, - totalProducts -}: { - products: SelectProduct[]; - offset: number; - totalProducts: number; -}) { - let router = useRouter(); - let productsPerPage = 5; - - function prevPage() { - router.back(); - } - - function nextPage() { - router.push(`/?offset=${offset}`, { scroll: false }); - } - - return ( - - - Products - - Manage your products and view their sales performance. - - - - - - - - Image - - Name - Status - Price - - Total Sales - - Created at - - Actions - - - - - {products.map((product) => ( - - ))} - -
-
- -
-
- Showing{' '} - - {Math.max(0, Math.min(offset - productsPerPage, totalProducts) + 1)}-{offset} - {' '} - of {totalProducts} products -
-
- - -
-
-
-
- ); -} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 640fc46f..00000000 --- a/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { handlers } from '@/lib/auth'; -export const { GET, POST } = handlers; diff --git a/app/api/seed/route.ts b/app/api/seed/route.ts deleted file mode 100644 index 185aae30..00000000 --- a/app/api/seed/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -// import { db, products } from 'lib/db'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - return Response.json({ - message: 'Uncomment to seed data after DB is set up.' - }); - - // await db.insert(products).values([ - // { - // id: 1, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/smartphone-gaPvyZW6aww0IhD3dOpaU6gBGILtcJ.webp', - // name: 'Smartphone X Pro', - // status: 'active', - // price: '999.00', - // stock: 150, - // availableAt: new Date() - // }, - // { - // id: 2, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/earbuds-3rew4JGdIK81KNlR8Edr8NBBhFTOtX.webp', - // name: 'Wireless Earbuds Ultra', - // status: 'active', - // price: '199.00', - // stock: 300, - // availableAt: new Date() - // }, - // { - // id: 3, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/home-iTeNnmKSMnrykOS9IYyJvnLFgap7Vw.webp', - // name: 'Smart Home Hub', - // status: 'active', - // price: '149.00', - // stock: 200, - // availableAt: new Date() - // }, - // { - // id: 4, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/tv-H4l26crxtm9EQHLWc0ddrsXZ0V0Ofw.webp', - // name: '4K Ultra HD Smart TV', - // status: 'active', - // price: '799.00', - // stock: 50, - // availableAt: new Date() - // }, - // { - // id: 5, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/laptop-9bgUhjY491hkxiMDeSgqb9R5I3lHNL.webp', - // name: 'Gaming Laptop Pro', - // status: 'active', - // price: '1299.00', - // stock: 75, - // availableAt: new Date() - // }, - // { - // id: 6, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/headset-lYnRnpjDbZkB78lS7nnqEJFYFAUDg6.webp', - // name: 'VR Headset Plus', - // status: 'active', - // price: '349.00', - // stock: 120, - // availableAt: new Date() - // }, - // { - // id: 7, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/watch-S2VeARK6sEM9QFg4yNQNjHFaHc3sXv.webp', - // name: 'Smartwatch Elite', - // status: 'active', - // price: '249.00', - // stock: 250, - // availableAt: new Date() - // }, - // { - // id: 8, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/speaker-4Zk0Ctx5AvxnwNNTFWVK4Gtpru4YEf.webp', - // name: 'Bluetooth Speaker Max', - // status: 'active', - // price: '99.00', - // stock: 400, - // availableAt: new Date() - // }, - // { - // id: 9, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/charger-GzRr0NSkCj0ZYWkTMvxXGZQu47w9r5.webp', - // name: 'Portable Charger Super', - // status: 'active', - // price: '59.00', - // stock: 500, - // availableAt: new Date() - // }, - // { - // id: 10, - // imageUrl: - // 'https://uwja77bygk2kgfqe.public.blob.vercel-storage.com/thermostat-8GnK2LDE3lZAjUVtiBk61RrSuqSTF7.webp', - // name: 'Smart Thermostat Pro', - // status: 'active', - // price: '199.00', - // stock: 175, - // availableAt: new Date() - // } - // ]); -} diff --git a/app/dashboard/actions.ts b/app/dashboard/actions.ts new file mode 100644 index 00000000..1525d992 --- /dev/null +++ b/app/dashboard/actions.ts @@ -0,0 +1,610 @@ +'use server'; + +import { + deleteExpenseById, + deleteCategoryById, + createExpense, + updateExpense as updateExpenseInDb, + createCategory, + updateCategory as updateCategoryInDb, + createPaymentMethod, + updatePaymentMethod as updatePaymentMethodInDb, + deletePaymentMethodById, + createIncome, + updateIncome as updateIncomeInDb, + deleteIncomeById, + createIncomeCategory, + updateIncomeCategory as updateIncomeCategoryInDb, + deleteIncomeCategoryById +} from '@/lib/db'; +import { revalidatePath } from 'next/cache'; +import { getUser } from '@/lib/auth'; +import { + expenseSchema, + updateExpenseSchema, + incomeSchema, + updateIncomeSchema, + deleteIncomeSchema, + categorySchema, + updateCategorySchema, + deleteCategorySchema, + paymentMethodSchema, + updatePaymentMethodSchema, + deletePaymentMethodSchema, + validateFormData +} from '@/lib/validations/schemas'; + +export async function saveExpense(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(expenseSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await createExpense({ + user_id: user.id, + category_id: data.categoryId, + amount: data.amount, + description: data.description, + date: data.date, + payment_method: String(data.paymentMethodId), + payment_status: data.paymentStatus as 'pagado' | 'pendiente' | 'vencido', + is_recurring: data.isRecurring ? 1 : 0, + recurrence_frequency: data.isRecurring ? data.recurrenceFrequency : null, + notes: data.notes + }); + + revalidatePath('/dashboard/gastos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Error desconocido'; + console.error('[saveExpense]', { error: message }); + + if (message.includes('constraint') || message.includes('foreign key')) { + return { error: 'Datos inválidos. Verifica la categoría y método de pago.' }; + } + + return { error: 'Error al guardar el gasto. Intenta nuevamente.' }; + } +} + +export async function updateExpense(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(updateExpenseSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await updateExpenseInDb(data.id, { + category_id: data.categoryId, + amount: data.amount, + description: data.description, + date: data.date, + payment_method: String(data.paymentMethodId), + payment_status: data.paymentStatus as 'pagado' | 'pendiente' | 'vencido', + is_recurring: data.isRecurring ? 1 : 0, + recurrence_frequency: data.isRecurring ? data.recurrenceFrequency : null, + notes: data.notes + }); + + revalidatePath('/dashboard/gastos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Error desconocido'; + console.error('[updateExpense]', { error: message }); + + if (message.includes('constraint') || message.includes('foreign key')) { + return { error: 'Datos inválidos. Verifica la categoría y método de pago.' }; + } + + return { error: 'Error al actualizar el gasto. Intenta nuevamente.' }; + } +} + +export async function deleteExpense(formData: FormData) { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + const id = Number(formData.get('id')); + await deleteExpenseById(id); + revalidatePath('/dashboard/gastos'); + revalidatePath('/dashboard'); +} + +export async function markExpenseAsPaid(expenseId: number) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + await updateExpenseInDb(expenseId, { + payment_status: 'pagado' + }); + + revalidatePath('/dashboard/gastos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al marcar gasto como pagado:', error); + return { error: 'Error al marcar el gasto como pagado' }; + } +} + +export async function deleteCategory(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(deleteCategorySchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await deleteCategoryById(data.id); + revalidatePath('/dashboard/categorias'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al eliminar categoría:', error); + return { error: 'Error al eliminar la categoría' }; + } +} + +export async function updateCategory(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(updateCategorySchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await updateCategoryInDb(data.id, { + name: data.name, + color: data.color, + icon: data.icon || null, + description: data.description || null + }); + + revalidatePath('/dashboard/categorias'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al actualizar categoría:', error); + return { error: 'Error al actualizar la categoría' }; + } +} + +export async function saveCategory(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(categorySchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await createCategory({ + user_id: user.id, + name: data.name, + color: data.color, + icon: data.icon || null, + description: data.description || null + }); + + revalidatePath('/dashboard/categorias'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al guardar categoría:', error); + return { error: 'Error al guardar la categoría' }; + } +} + +export async function payRecurringExpense(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + const userId = user.id; + + const nextDate = formData.get('nextDate') as string; + const amount = formData.get('amount') as string; + const description = formData.get('description') as string; + const categoryId = formData.get('categoryId') as string; + const paymentMethodId = formData.get('paymentMethodId') as string; + const notes = formData.get('notes') as string; + + // Crear el gasto real para esta instancia recurrente + await createExpense({ + user_id: userId, + category_id: parseInt(categoryId), + amount, + description: `${description} (${nextDate})`, + date: nextDate, + payment_method: paymentMethodId, + payment_status: 'pagado', + is_recurring: 0, // Marcar como no recurrente (es una instancia individual) + recurrence_frequency: null, + notes + }); + + revalidatePath('/dashboard/gastos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al pagar gasto recurrente:', error); + return { error: 'Error al pagar el gasto recurrente' }; + } +} + +export async function savePaymentMethod(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(paymentMethodSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + // Validar tipo de método de pago + const validTypes = ['tarjeta_credito', 'tarjeta_debito', 'efectivo', 'transferencia', 'otro']; + if (!validTypes.includes(data.type)) { + return { error: 'Tipo de método de pago inválido' }; + } + + await createPaymentMethod({ + user_id: user.id, + name: data.name, + type: data.type as 'tarjeta_credito' | 'tarjeta_debito' | 'efectivo' | 'transferencia' | 'otro', + bank: data.bank || null, + last_four_digits: data.lastFourDigits || null, + icon: null, + color: data.color, + is_default: data.isDefault + }); + + revalidatePath('/dashboard/metodos-pago'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al guardar método de pago:', error); + return { error: 'Error al guardar el método de pago' }; + } +} + +export async function updatePaymentMethod(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(updatePaymentMethodSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + // Validar tipo de método de pago + const validTypes = ['tarjeta_credito', 'tarjeta_debito', 'efectivo', 'transferencia', 'otro']; + if (!validTypes.includes(data.type)) { + return { error: 'Tipo de método de pago inválido' }; + } + + await updatePaymentMethodInDb(data.id, { + name: data.name, + type: data.type as 'tarjeta_credito' | 'tarjeta_debito' | 'efectivo' | 'transferencia' | 'otro', + bank: data.bank || null, + last_four_digits: data.lastFourDigits || null, + icon: null, + color: data.color, + is_default: data.isDefault + }); + + revalidatePath('/dashboard/metodos-pago'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al actualizar método de pago:', error); + return { error: 'Error al actualizar el método de pago' }; + } +} + +export async function deletePaymentMethod(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(deletePaymentMethodSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await deletePaymentMethodById(data.id); + revalidatePath('/dashboard/metodos-pago'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al eliminar método de pago:', error); + return { error: 'Error al eliminar el método de pago' }; + } +} + +//============================================================================== +// SERVER ACTIONS - Ingresos +//============================================================================== + +export async function saveIncome(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(incomeSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await createIncome({ + user_id: user.id, + source: data.source, + amount: data.amount, + date: data.date, + description: data.description || null, + category_id: data.categoryId, + payment_method: null, // Los ingresos no requieren método de pago + is_recurring: data.isRecurring ? 1 : 0, + recurrence_frequency: data.isRecurring ? data.recurrenceFrequency : null, + notes: null + }); + + revalidatePath('/dashboard/ingresos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al guardar ingreso:', error); + return { error: 'Error al guardar el ingreso' }; + } +} + +export async function updateIncome(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(updateIncomeSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await updateIncomeInDb(data.id, { + source: data.source, + amount: data.amount, + date: data.date, + description: data.description || null, + category_id: data.categoryId, + payment_method: null, + is_recurring: data.isRecurring ? 1 : 0, + recurrence_frequency: data.isRecurring ? data.recurrenceFrequency : null, + notes: null + }); + + revalidatePath('/dashboard/ingresos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al actualizar ingreso:', error); + return { error: 'Error al actualizar el ingreso' }; + } +} + +export async function deleteIncome(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(deleteIncomeSchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await deleteIncomeById(data.id); + revalidatePath('/dashboard/ingresos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al eliminar ingreso:', error); + return { error: 'Error al eliminar el ingreso' }; + } +} + +//============================================================================== +// SERVER ACTIONS - Categorías de Ingresos +//============================================================================== + +export async function saveIncomeCategory(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(categorySchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await createIncomeCategory({ + user_id: user.id, + name: data.name, + color: data.color, + icon: data.icon || null, + description: data.description || null + }); + + revalidatePath('/dashboard/ingresos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al guardar categoría de ingreso:', error); + return { error: 'Error al guardar la categoría' }; + } +} + +export async function updateIncomeCategoryAction(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(updateCategorySchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await updateIncomeCategoryInDb(data.id, { + name: data.name, + color: data.color, + icon: data.icon || null, + description: data.description || null + }); + + revalidatePath('/dashboard/ingresos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al actualizar categoría de ingreso:', error); + return { error: 'Error al actualizar la categoría' }; + } +} + +export async function deleteIncomeCategory(formData: FormData) { + try { + const user = await getUser(); + + if (!user) { + return { error: 'No estás autenticado' }; + } + + // Validar datos con Zod + const validation = validateFormData(deleteCategorySchema, formData); + + if (!validation.success) { + return { error: validation.error }; + } + + const data = validation.data; + + await deleteIncomeCategoryById(data.id); + revalidatePath('/dashboard/ingresos'); + revalidatePath('/dashboard'); + return { success: true }; + } catch (error) { + console.error('Error al eliminar categoría de ingreso:', error); + return { error: 'Error al eliminar la categoría' }; + } +} diff --git a/app/dashboard/categorias/[id]/category-header.tsx b/app/dashboard/categorias/[id]/category-header.tsx new file mode 100644 index 00000000..9fbdb2b9 --- /dev/null +++ b/app/dashboard/categorias/[id]/category-header.tsx @@ -0,0 +1,63 @@ +'use client'; + +import Link from 'next/link'; +import { ChevronRight, ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +type Category = { + id: number; + name: string; + color: string; + icon?: string | null; + description?: string | null; +}; + +export function CategoryHeader({ category }: { category: Category }) { + return ( +
+ {/* Breadcrumb navigation */} +
+ + Dashboard + + + + Categorías + + + {category.name} +
+ + {/* Back button for mobile */} +
+ +
+ + {/* Category display */} +
+
+ {category.icon || '📦'} +
+
+

{category.name}

+ {category.description && ( +

+ {category.description} +

+ )} +
+
+
+ ); +} diff --git a/app/dashboard/categorias/[id]/category-stats-cards.tsx b/app/dashboard/categorias/[id]/category-stats-cards.tsx new file mode 100644 index 00000000..1f18f93f --- /dev/null +++ b/app/dashboard/categorias/[id]/category-stats-cards.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { + DollarSign, + Hash, + BarChart2, + CheckCircle, + Clock, + AlertTriangle +} from 'lucide-react'; +import { formatCurrency } from '@/lib/utils/formatting'; + +type Statistics = { + totalSpent: number; + expenseCount: number; + averageExpense: number; + paidTotal: number; + pendingTotal: number; + overdueTotal: number; + paidCount: number; + pendingCount: number; + overdueCount: number; +}; + +export function CategoryStatsCards({ statistics }: { statistics: Statistics }) { + return ( +
+ {/* Total Spent */} +
+
+ +
Total Gastado
+
+
+ {formatCurrency(statistics.totalSpent)} +
+
+ En {statistics.expenseCount} {statistics.expenseCount === 1 ? 'gasto' : 'gastos'} +
+
+ + {/* Expense Count */} +
+
+ +
Cantidad de Gastos
+
+
{statistics.expenseCount}
+
Total de registros
+
+ + {/* Average Expense */} +
+
+ +
Promedio por Gasto
+
+
+ {formatCurrency(statistics.averageExpense)} +
+
Gasto promedio
+
+ + {/* Paid Total */} +
+
+ +
Pagado
+
+
+ {formatCurrency(statistics.paidTotal)} +
+
+ {statistics.paidCount} {statistics.paidCount === 1 ? 'gasto' : 'gastos'} +
+
+ + {/* Pending Total */} +
+
+ +
Pendiente
+
+
+ {formatCurrency(statistics.pendingTotal)} +
+
+ {statistics.pendingCount} {statistics.pendingCount === 1 ? 'gasto' : 'gastos'} +
+
+ + {/* Overdue Total */} +
+
+ +
Vencido
+
+
+ {formatCurrency(statistics.overdueTotal)} +
+
+ {statistics.overdueCount} {statistics.overdueCount === 1 ? 'gasto' : 'gastos'} +
+
+
+ ); +} diff --git a/app/dashboard/categorias/[id]/category-trend-chart.tsx b/app/dashboard/categorias/[id]/category-trend-chart.tsx new file mode 100644 index 00000000..7882cd6d --- /dev/null +++ b/app/dashboard/categorias/[id]/category-trend-chart.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useState } from 'react'; +import { LineChart as LineChartIcon, BarChart3 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@/components/ui/card'; +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer +} from 'recharts'; +import { formatCurrency } from '@/lib/utils/formatting'; + +type Category = { + id: number; + name: string; + color: string; + icon?: string | null; +}; + +type MonthlyData = { + month: string; + total: number; + count: number; +}; + +type ViewMode = 'line' | 'bar'; + +export function CategoryTrendChart({ + data, + category +}: { + data: MonthlyData[]; + category: Category; +}) { + const [viewMode, setViewMode] = useState('line'); + + // Format month for display (e.g., "2024-01" -> "Ene 2024") + const formatMonth = (monthStr: string) => { + const [year, month] = monthStr.split('-'); + const date = new Date(parseInt(year), parseInt(month) - 1); + return date.toLocaleDateString('es-MX', { month: 'short', year: 'numeric' }); + }; + + const chartData = data.map((item) => ({ + month: formatMonth(item.month), + total: item.total, + count: item.count, + average: item.count > 0 ? item.total / item.count : 0 + })); + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.month}

+

+ Total: {formatCurrency(data.total)} +

+

+ Gastos: {data.count} +

+

+ Promedio: {formatCurrency(data.average)} +

+
+ ); + } + return null; + }; + + if (chartData.length === 0) { + return ( + + + + + Evolución de Gastos + + Últimos 6 meses + + +
+ +

+ Sin datos disponibles +

+

+ No hay gastos registrados en los últimos meses +

+
+
+
+ ); + } + + return ( + + +
+
+ + + Evolución de Gastos + + Últimos 6 meses en {category.name} +
+
+ + +
+
+
+ + + {viewMode === 'line' ? ( + + + + + new Intl.NumberFormat('es-MX', { + notation: 'compact', + compactDisplay: 'short' + }).format(value) + } + /> + } /> + + + ) : ( + + + + + new Intl.NumberFormat('es-MX', { + notation: 'compact', + compactDisplay: 'short' + }).format(value) + } + /> + } /> + + + )} + + +
+ ); +} diff --git a/app/dashboard/categorias/[id]/page.tsx b/app/dashboard/categorias/[id]/page.tsx new file mode 100644 index 00000000..c46452c4 --- /dev/null +++ b/app/dashboard/categorias/[id]/page.tsx @@ -0,0 +1,103 @@ +import { notFound } from 'next/navigation'; +import { getUser } from '@/lib/auth'; +import { + getCategoryById, + getExpensesByCategoryId, + getCategoryStatistics, + getCategoryMonthlyTrend, + getCategoriesByUser, + getPaymentMethodsByUser +} from '@/lib/db'; +import { CategoryHeader } from './category-header'; +import { CategoryStatsCards } from './category-stats-cards'; +import { CategoryTrendChart } from './category-trend-chart'; +import { ExpensesTable } from '../../gastos/expenses-table'; +import { AddExpenseDialog } from '../../gastos/add-expense-dialog'; +import { EditCategoryDialog } from '../edit-category-dialog'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export const dynamic = 'force-dynamic'; + +export default async function CategoryDetailsPage({ + params +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const categoryId = parseInt(id); + const user = await getUser(); + + if (!user) { + return
No autenticado
; + } + + // Fetch all data in parallel + const [category, expenses, statistics, monthlyTrend, allCategories, paymentMethods] = + await Promise.all([ + getCategoryById(user.id, categoryId), + getExpensesByCategoryId(user.id, categoryId), + getCategoryStatistics(user.id, categoryId), + getCategoryMonthlyTrend(user.id, categoryId, 6), + getCategoriesByUser(user.id), + getPaymentMethodsByUser(user.id) + ]); + + // Handle 404 + if (!category) { + notFound(); + } + + return ( +
+ + + + +
+ + + + + Acciones Rápidas + + + {allCategories.length > 0 && paymentMethods.length > 0 && ( + + )} + + + +
+ + {expenses.length === 0 ? ( + + +

+ No hay gastos en esta categoría +

+ {allCategories.length > 0 && paymentMethods.length > 0 && ( + + )} +
+
+ ) : ( + + )} +
+ ); +} diff --git a/app/dashboard/categorias/add-category-dialog.tsx b/app/dashboard/categorias/add-category-dialog.tsx new file mode 100644 index 00000000..b1767dfb --- /dev/null +++ b/app/dashboard/categorias/add-category-dialog.tsx @@ -0,0 +1,370 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { PlusCircle, Search } from 'lucide-react'; +import { saveCategory } from '../actions'; +import { useRouter } from 'next/navigation'; +import { COLORS } from '@/lib/constants/colors'; +import { useToast } from '@/hooks/use-toast'; + +const ICON_CATEGORIES = { + comida: { + name: 'Comida & Bebidas', + icons: [ + { emoji: '🍔', name: 'hamburguesa' }, + { emoji: '🍕', name: 'pizza' }, + { emoji: '🍝', name: 'pasta' }, + { emoji: '🍜', name: 'ramen' }, + { emoji: '🍱', name: 'bento' }, + { emoji: '🍛', name: 'curry' }, + { emoji: '🍗', name: 'pollo' }, + { emoji: '🥗', name: 'ensalada' }, + { emoji: '🥙', name: 'taco' }, + { emoji: '🌮', name: 'tacos' }, + { emoji: '🌯', name: 'burrito' }, + { emoji: '☕', name: 'cafe' }, + { emoji: '🍺', name: 'cerveza' }, + { emoji: '🍷', name: 'vino' }, + { emoji: '🥤', name: 'refresco' }, + { emoji: '🧃', name: 'jugo' }, + ] + }, + transporte: { + name: 'Transporte', + icons: [ + { emoji: '🚗', name: 'carro auto' }, + { emoji: '🚕', name: 'taxi' }, + { emoji: '🚙', name: 'camioneta' }, + { emoji: '🚌', name: 'autobus' }, + { emoji: '🚎', name: 'transporte publico' }, + { emoji: '🚐', name: 'van' }, + { emoji: '🚓', name: 'policia' }, + { emoji: '🚑', name: 'ambulancia' }, + { emoji: '✈️', name: 'avion vuelo' }, + { emoji: '🚆', name: 'tren' }, + { emoji: '🚲', name: 'bicicleta' }, + { emoji: '🛴', name: 'scooter' }, + { emoji: '⛽', name: 'gasolina' }, + { emoji: '🅿️', name: 'estacionamiento' }, + ] + }, + hogar: { + name: 'Hogar & Servicios', + icons: [ + { emoji: '🏠', name: 'casa hogar' }, + { emoji: '🏡', name: 'casa jardin' }, + { emoji: '🏢', name: 'oficina edificio' }, + { emoji: '🛋️', name: 'sofa muebles' }, + { emoji: '🛏️', name: 'cama' }, + { emoji: '🚿', name: 'ducha bano' }, + { emoji: '🚽', name: 'bano' }, + { emoji: '🔧', name: 'herramienta reparacion' }, + { emoji: '🔨', name: 'martillo construccion' }, + { emoji: '⚡', name: 'electricidad luz' }, + { emoji: '💡', name: 'foco luz' }, + { emoji: '🔌', name: 'enchufe' }, + { emoji: '📺', name: 'television' }, + { emoji: '📞', name: 'telefono' }, + { emoji: '📡', name: 'internet wifi' }, + ] + }, + entretenimiento: { + name: 'Entretenimiento', + icons: [ + { emoji: '🎮', name: 'videojuegos gaming' }, + { emoji: '🎬', name: 'cine peliculas' }, + { emoji: '🎭', name: 'teatro' }, + { emoji: '🎪', name: 'circo' }, + { emoji: '🎨', name: 'arte pintura' }, + { emoji: '🎵', name: 'musica' }, + { emoji: '🎸', name: 'guitarra' }, + { emoji: '🎹', name: 'piano' }, + { emoji: '🎤', name: 'microfono karaoke' }, + { emoji: '🎧', name: 'audifonos' }, + { emoji: '📚', name: 'libros lectura' }, + { emoji: '📖', name: 'libro' }, + { emoji: '🎯', name: 'objetivo meta' }, + { emoji: '🎲', name: 'dados juego' }, + { emoji: '🃏', name: 'cartas poker' }, + ] + }, + salud: { + name: 'Salud & Deporte', + icons: [ + { emoji: '❤️', name: 'corazon salud' }, + { emoji: '💊', name: 'medicina pastilla' }, + { emoji: '💉', name: 'inyeccion vacuna' }, + { emoji: '🏥', name: 'hospital clinica' }, + { emoji: '⚕️', name: 'medico doctor' }, + { emoji: '🩺', name: 'estetoscopio' }, + { emoji: '😷', name: 'cubrebocas' }, + { emoji: '🏋️', name: 'gimnasio pesas' }, + { emoji: '🤸', name: 'ejercicio' }, + { emoji: '🧘', name: 'yoga meditacion' }, + { emoji: '🏃', name: 'correr running' }, + { emoji: '🚴', name: 'ciclismo' }, + { emoji: '🏊', name: 'natacion' }, + { emoji: '⚽', name: 'futbol' }, + { emoji: '🏀', name: 'basketball' }, + { emoji: '🎾', name: 'tenis' }, + ] + }, + finanzas: { + name: 'Finanzas & Compras', + icons: [ + { emoji: '💰', name: 'dinero bolsa' }, + { emoji: '💵', name: 'dolar billete' }, + { emoji: '💳', name: 'tarjeta credito' }, + { emoji: '🏦', name: 'banco' }, + { emoji: '💎', name: 'diamante joya' }, + { emoji: '👔', name: 'corbata ropa formal' }, + { emoji: '👕', name: 'camisa ropa' }, + { emoji: '👗', name: 'vestido' }, + { emoji: '👟', name: 'tenis zapatos' }, + { emoji: '🛍️', name: 'compras shopping' }, + { emoji: '🛒', name: 'carrito supermercado' }, + { emoji: '🎁', name: 'regalo' }, + { emoji: '💍', name: 'anillo' }, + { emoji: '⌚', name: 'reloj' }, + ] + }, + otros: { + name: 'Otros', + icons: [ + { emoji: '📦', name: 'paquete caja' }, + { emoji: '📝', name: 'nota documento' }, + { emoji: '📊', name: 'grafica estadistica' }, + { emoji: '📈', name: 'crecimiento' }, + { emoji: '📉', name: 'decrecimiento' }, + { emoji: '🔔', name: 'campana notificacion' }, + { emoji: '⭐', name: 'estrella favorito' }, + { emoji: '🎓', name: 'graduacion educacion' }, + { emoji: '✏️', name: 'lapiz' }, + { emoji: '📱', name: 'celular telefono' }, + { emoji: '💻', name: 'computadora laptop' }, + { emoji: '🖥️', name: 'monitor pc' }, + { emoji: '⚙️', name: 'configuracion' }, + { emoji: '🔒', name: 'candado seguridad' }, + { emoji: '🌟', name: 'brillo especial' }, + ] + } +}; + +export function AddCategoryDialog() { + const [open, setOpen] = useState(false); + const [selectedColor, setSelectedColor] = useState(COLORS[0].value); + const [selectedIcon, setSelectedIcon] = useState('📦'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [activeTab, setActiveTab] = useState('comida'); + const router = useRouter(); + const { toast } = useToast(); + + // Filtrar iconos según la búsqueda + const filteredIcons = useMemo(() => { + if (!searchQuery.trim()) { + return ICON_CATEGORIES[activeTab].icons; + } + + const query = searchQuery.toLowerCase().trim(); + const allIcons = Object.values(ICON_CATEGORIES).flatMap(cat => cat.icons); + + return allIcons.filter(icon => + icon.name.toLowerCase().includes(query) + ); + }, [searchQuery, activeTab]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + const formData = new FormData(e.currentTarget); + formData.set('color', selectedColor); + formData.set('icon', selectedIcon); + + const result = await saveCategory(formData); + + if (result?.error) { + toast({ + title: 'Error al guardar', + description: result.error, + variant: 'destructive' + }); + } else { + toast({ + title: 'Categoría creada', + description: 'La categoría se ha creado exitosamente' + }); + setOpen(false); + router.refresh(); + // Reset form + e.currentTarget.reset(); + setSelectedColor(COLORS[0].value); + setSelectedIcon('📦'); + setSearchQuery(''); + setActiveTab('comida'); + } + + setIsSubmitting(false); + }; + + return ( + + + + + + + Agregar Nueva Categoría + + Crea una categoría para organizar tus gastos. + + +
+
+ + +
+ +
+ +
+ {COLORS.map((color) => ( +
+
+ +
+ + + {/* Buscador de iconos */} +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + {/* Pestañas de categorías - solo mostrar si no hay búsqueda */} + {!searchQuery && ( +
+ {Object.entries(ICON_CATEGORIES).map(([key, category]) => ( + + ))} +
+ )} + + {/* Grid de iconos con scroll */} +
+
+ {filteredIcons.map((icon) => ( + + ))} +
+ {filteredIcons.length === 0 && ( +
+ No se encontraron iconos para "{searchQuery}" +
+ )} +
+
+ +
+ +