feat: Implement Phase 3 & 4 - AI Reports & ERP Integrations

## Phase 3: DeepSeek AI Integration

### AI Services (apps/api/src/services/ai/)
- DeepSeek API client with streaming, rate limiting, retries
- AI service for financial analysis, executive summaries, recommendations
- Optimized prompts for Mexican CFO digital assistant
- Redis caching for AI responses

### Report Generation (apps/api/src/services/reports/)
- ReportGenerator: monthly, quarterly, annual, custom reports
- PDF generator with corporate branding (PDFKit)
- Report templates for PYME, Startup, Enterprise
- Spanish prompts for financial narratives
- MinIO storage for generated PDFs

### AI & Reports API Routes
- POST /api/ai/analyze - Financial metrics analysis
- POST /api/ai/chat - CFO digital chat with streaming
- POST /api/reports/generate - Async report generation
- GET /api/reports/:id/download - PDF download
- BullMQ jobs for background processing

### Frontend Pages
- /reportes - Report listing with filters
- /reportes/nuevo - 3-step wizard for report generation
- /reportes/[id] - Full report viewer with charts
- /asistente - CFO Digital chat interface
- Components: ChatInterface, ReportCard, AIInsightCard

## Phase 4: ERP Integrations

### CONTPAQi Connector (SQL Server)
- Contabilidad: catalog, polizas, balanza, estado de resultados
- Comercial: clientes, proveedores, facturas, inventario
- Nominas: empleados, nominas, percepciones/deducciones

### Aspel Connector (Firebird/SQL Server)
- COI: accounting, polizas, balanza, auxiliares
- SAE: sales, purchases, inventory, A/R, A/P
- NOI: payroll, employees, receipts
- BANCO: bank accounts, movements, reconciliation
- Latin1 to UTF-8 encoding handling

### Odoo Connector (XML-RPC)
- Accounting: chart of accounts, journal entries, reports
- Invoicing: customer/vendor invoices, payments
- Partners: customers, vendors, statements
- Inventory: products, stock levels, valuations
- Multi-company and version 14-17 support

### Alegra Connector (REST API)
- Invoices, credit/debit notes with CFDI support
- Contacts with Mexican fiscal fields (RFC, regimen)
- Payments and bank reconciliation
- Financial reports: trial balance, P&L, balance sheet
- Webhook support for real-time sync

### SAP Business One Connector (OData/Service Layer)
- Session management with auto-refresh
- Financials: chart of accounts, journal entries, reports
- Sales: invoices, credit notes, delivery notes, orders
- Purchasing: bills, POs, goods receipts
- Inventory: items, stock, transfers, valuation
- Banking: accounts, payments, cash flow

### Integration Manager
- Unified interface for all connectors
- BullMQ scheduler for automated sync
- Webhook handling for real-time updates
- Migration 003_integrations.sql with sync tables
- Frontend page /integraciones for configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 11:25:17 +00:00
parent a9b1994c48
commit 45570baccc
88 changed files with 52538 additions and 531 deletions

View File

@@ -0,0 +1,455 @@
'use client';
import React, { useState, useEffect } from 'react';
import { cn, formatCurrency, formatPercentage } from '@/lib/utils';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
ChatInterface,
SuggestedQuestions,
defaultSuggestedQuestions,
AIInsightCard,
InsightsList,
AIInsight,
SuggestedQuestion,
} from '@/components/ai';
import {
Bot,
Sparkles,
TrendingUp,
TrendingDown,
AlertTriangle,
Info,
Lightbulb,
BarChart3,
DollarSign,
Users,
Clock,
ArrowRight,
MessageSquare,
History,
Settings,
ChevronRight,
} from 'lucide-react';
// ============================================================================
// Mock Data
// ============================================================================
const mockInsights: AIInsight[] = [
{
id: '1',
type: 'positive',
priority: 'high',
title: 'Crecimiento de MRR sostenido',
description: 'Tu MRR ha crecido un 5.9% este mes, manteniendose por encima del promedio de la industria.',
metric: {
label: 'MRR Actual',
value: 125000,
change: 5.9,
format: 'currency',
},
action: {
label: 'Ver detalle de metricas',
onClick: () => console.log('Ver metricas'),
},
timestamp: new Date(),
},
{
id: '2',
type: 'warning',
priority: 'high',
title: 'Facturas vencidas requieren atencion',
description: 'Tienes 3 facturas vencidas por un total de $15,750. Considera enviar recordatorios.',
metric: {
label: 'Monto Vencido',
value: 15750,
format: 'currency',
},
action: {
label: 'Ver cuentas por cobrar',
onClick: () => console.log('Ver cuentas'),
},
timestamp: new Date(),
},
{
id: '3',
type: 'suggestion',
priority: 'medium',
title: 'Oportunidad de ahorro en gastos',
description: 'Identificamos suscripciones de software duplicadas que podrian consolidarse.',
metric: {
label: 'Ahorro Potencial',
value: 2500,
format: 'currency',
},
action: {
label: 'Revisar gastos',
},
timestamp: new Date(),
},
{
id: '4',
type: 'positive',
priority: 'low',
title: 'LTV/CAC ratio excelente',
description: 'Tu ratio LTV/CAC de 6.0x indica una excelente eficiencia en adquisicion de clientes.',
metric: {
label: 'LTV/CAC',
value: '6.0x',
},
timestamp: new Date(),
},
{
id: '5',
type: 'info',
priority: 'medium',
title: 'Proyeccion de flujo de caja',
description: 'Basado en tendencias actuales, tu flujo de caja proyectado para el proximo mes es positivo.',
metric: {
label: 'Flujo Proyectado',
value: 52000,
change: 12.5,
format: 'currency',
},
timestamp: new Date(),
},
];
const recentConversations = [
{ id: '1', title: 'Analisis de flujo de caja', date: 'Hace 2 horas', messages: 5 },
{ id: '2', title: 'Proyeccion Q4 2024', date: 'Ayer', messages: 12 },
{ id: '3', title: 'Optimizacion de gastos', date: 'Hace 2 dias', messages: 8 },
{ id: '4', title: 'Metricas SaaS', date: 'Hace 3 dias', messages: 6 },
];
const quickMetrics = [
{ label: 'MRR', value: 125000, change: 5.9, format: 'currency' as const },
{ label: 'Churn', value: 2.5, change: -0.7, format: 'percentage' as const, inverse: true },
{ label: 'Clientes', value: 342, change: 8.5, format: 'number' as const },
{ label: 'LTV/CAC', value: 6.0, change: 25, format: 'number' as const },
];
// ============================================================================
// Quick Metrics Bar
// ============================================================================
function QuickMetricsBar() {
const formatValue = (value: number, format: 'currency' | 'percentage' | 'number') => {
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
default:
return value.toLocaleString('es-ES');
}
};
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{quickMetrics.map((metric) => {
const isPositive = metric.inverse ? metric.change <= 0 : metric.change >= 0;
return (
<div
key={metric.label}
className="p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
{metric.label}
</span>
<span
className={cn(
'flex items-center gap-0.5 text-xs font-medium',
isPositive
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'
)}
>
{isPositive ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{formatPercentage(Math.abs(metric.change))}
</span>
</div>
<p className="text-lg font-bold text-slate-900 dark:text-white mt-1">
{formatValue(metric.value, metric.format)}
</p>
</div>
);
})}
</div>
);
}
// ============================================================================
// Recent Conversations
// ============================================================================
interface RecentConversationsProps {
conversations: typeof recentConversations;
onSelect: (id: string) => void;
}
function RecentConversations({ conversations, onSelect }: RecentConversationsProps) {
return (
<div className="space-y-2">
{conversations.map((conv) => (
<button
key={conv.id}
onClick={() => onSelect(conv.id)}
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-left transition-colors group"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 group-hover:bg-slate-200 dark:group-hover:bg-slate-600">
<MessageSquare className="w-4 h-4 text-slate-500 dark:text-slate-400" />
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-white">
{conv.title}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{conv.date} - {conv.messages} mensajes
</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
))}
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function AsistentePage() {
const [isFullChat, setIsFullChat] = useState(false);
const [insights, setInsights] = useState<AIInsight[]>(mockInsights);
const handleSuggestedQuestion = (question: SuggestedQuestion) => {
setIsFullChat(true);
// El componente ChatInterface manejara la pregunta
};
const handleInsightAction = (insight: AIInsight) => {
console.log('Insight action:', insight.id);
};
const handleSelectConversation = (id: string) => {
console.log('Selected conversation:', id);
setIsFullChat(true);
};
// Vista de chat completo
if (isFullChat) {
return (
<div className="h-[calc(100vh-8rem)]">
<ChatInterface
suggestedQuestions={defaultSuggestedQuestions}
className="h-full rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"
/>
</div>
);
}
// Vista de dashboard del asistente
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
<Bot className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
CFO Digital
</h1>
<p className="text-slate-500 dark:text-slate-400">
Tu asistente financiero impulsado por IA
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
leftIcon={<History className="w-4 h-4" />}
>
Historial
</Button>
<Button
variant="primary"
size="sm"
leftIcon={<MessageSquare className="w-4 h-4" />}
onClick={() => setIsFullChat(true)}
>
Nueva Conversacion
</Button>
</div>
</div>
{/* Quick Metrics */}
<QuickMetricsBar />
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Chat Preview & Suggestions */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Chat Card */}
<Card>
<CardHeader
title="Pregunta al CFO Digital"
subtitle="Obten respuestas instantaneas sobre tus finanzas"
action={
<Button
variant="ghost"
size="xs"
rightIcon={<ArrowRight className="w-4 h-4" />}
onClick={() => setIsFullChat(true)}
>
Chat completo
</Button>
}
/>
<CardContent>
{/* Quick Input */}
<div className="relative mb-4">
<input
type="text"
placeholder="Escribe tu pregunta aqui..."
onClick={() => setIsFullChat(true)}
readOnly
className={cn(
'w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700',
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white',
'cursor-pointer hover:border-primary-300 dark:hover:border-primary-600 transition-colors',
'placeholder:text-slate-400 dark:placeholder:text-slate-500'
)}
/>
<Sparkles className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-violet-500" />
</div>
{/* Suggested Questions */}
<SuggestedQuestions
questions={defaultSuggestedQuestions.slice(0, 4)}
onSelect={handleSuggestedQuestion}
title="Preguntas frecuentes:"
/>
</CardContent>
</Card>
{/* AI Insights */}
<Card>
<CardHeader
title="Insights de IA"
subtitle="Recomendaciones basadas en tus datos"
action={
<div className="flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400">
<Sparkles className="w-4 h-4" />
Actualizado hace 5 min
</div>
}
/>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{insights.slice(0, 4).map((insight) => (
<AIInsightCard
key={insight.id}
insight={insight}
compact
onAction={() => handleInsightAction(insight)}
/>
))}
</div>
{insights.length > 4 && (
<div className="mt-4 text-center">
<Button variant="ghost" size="sm">
Ver todos los insights ({insights.length})
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Recent Conversations */}
<Card>
<CardHeader
title="Conversaciones Recientes"
action={
<Button variant="ghost" size="xs">
Ver todas
</Button>
}
/>
<CardContent>
<RecentConversations
conversations={recentConversations}
onSelect={handleSelectConversation}
/>
</CardContent>
</Card>
{/* Capabilities */}
<Card>
<CardHeader title="Que puedo hacer" />
<CardContent>
<div className="space-y-3">
{[
{ icon: <BarChart3 className="w-4 h-4" />, label: 'Analizar metricas financieras' },
{ icon: <TrendingUp className="w-4 h-4" />, label: 'Proyectar ingresos y gastos' },
{ icon: <DollarSign className="w-4 h-4" />, label: 'Optimizar flujo de caja' },
{ icon: <Users className="w-4 h-4" />, label: 'Gestionar cuentas por cobrar' },
{ icon: <AlertTriangle className="w-4 h-4" />, label: 'Detectar alertas financieras' },
{ icon: <Lightbulb className="w-4 h-4" />, label: 'Sugerir optimizaciones' },
].map((item, index) => (
<div
key={index}
className="flex items-center gap-3 p-2 rounded-lg text-sm text-slate-700 dark:text-slate-300"
>
<span className="p-1.5 rounded-md bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
{item.icon}
</span>
{item.label}
</div>
))}
</div>
</CardContent>
</Card>
{/* Tips Card */}
<Card className="bg-gradient-to-br from-violet-500 to-purple-600 border-0 text-white">
<CardContent>
<Sparkles className="w-8 h-8 mb-3 opacity-80" />
<h3 className="font-semibold text-lg mb-2">
Tip del dia
</h3>
<p className="text-sm opacity-90 mb-4">
Pregunta por tus metricas SaaS para obtener un analisis completo
de la salud de tu negocio incluyendo MRR, Churn y LTV/CAC.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => {
setIsFullChat(true);
}}
className="bg-white/20 hover:bg-white/30 border-0 text-white"
>
Probar ahora
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,719 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
/**
* Tipos de integracion
*/
interface IntegrationProvider {
type: string;
name: string;
description: string;
category: 'erp' | 'accounting' | 'fiscal' | 'bank' | 'payments' | 'custom';
logoUrl?: string;
supportedEntities: string[];
supportedDirections: string[];
supportsRealtime: boolean;
supportsWebhooks: boolean;
isAvailable: boolean;
isBeta: boolean;
regions?: string[];
}
interface ConfiguredIntegration {
id: string;
type: string;
name: string;
status: 'pending' | 'active' | 'inactive' | 'error' | 'expired';
isActive: boolean;
lastSyncAt?: string;
lastSyncStatus?: string;
healthStatus?: 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
provider?: {
name: string;
category: string;
logoUrl?: string;
};
}
interface SyncStatus {
integration: {
id: string;
type: string;
name: string;
status: string;
isActive: boolean;
health: string;
};
lastSync?: {
jobId: string;
status: string;
startedAt: string;
completedAt?: string;
totalRecords: number;
createdRecords: number;
updatedRecords: number;
failedRecords: number;
errorCount: number;
lastError?: string;
};
nextSyncAt?: string;
}
/**
* Iconos para categorias
*/
const CategoryIcons: Record<string, React.ReactNode> = {
erp: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
accounting: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
fiscal: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
bank: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
),
payments: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
custom: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
};
/**
* Estado de salud badge
*/
const HealthBadge: React.FC<{ status?: string }> = ({ status }) => {
const styles: Record<string, string> = {
healthy: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
degraded: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
unhealthy: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
unknown: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
};
const labels: Record<string, string> = {
healthy: 'Saludable',
degraded: 'Degradado',
unhealthy: 'Error',
unknown: 'Desconocido',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status || 'unknown']}`}>
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
status === 'healthy' ? 'bg-green-500' :
status === 'degraded' ? 'bg-yellow-500' :
status === 'unhealthy' ? 'bg-red-500' : 'bg-gray-500'
}`} />
{labels[status || 'unknown']}
</span>
);
};
/**
* Estado de integracion badge
*/
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
const styles: Record<string, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
error: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
expired: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
};
const labels: Record<string, string> = {
active: 'Activa',
pending: 'Pendiente',
inactive: 'Inactiva',
error: 'Error',
expired: 'Expirada',
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || styles.inactive}`}>
{labels[status] || status}
</span>
);
};
/**
* Pagina de Integraciones
*/
export default function IntegracionesPage() {
const [providers, setProviders] = useState<IntegrationProvider[]>([]);
const [configured, setConfigured] = useState<ConfiguredIntegration[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [isLoading, setIsLoading] = useState(true);
const [selectedIntegration, setSelectedIntegration] = useState<ConfiguredIntegration | null>(null);
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [showConfigModal, setShowConfigModal] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<IntegrationProvider | null>(null);
// Simular carga de datos
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
// Simular API call - en produccion seria fetch real
await new Promise(resolve => setTimeout(resolve, 500));
// Datos de ejemplo
setProviders([
{
type: 'contpaqi',
name: 'CONTPAQi',
description: 'Integracion con sistemas CONTPAQi (Comercial, Contabilidad, Nominas)',
category: 'erp',
logoUrl: '/integrations/contpaqi-logo.svg',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'aspel',
name: 'Aspel',
description: 'Integracion con sistemas Aspel (SAE, COI, NOI, BANCO)',
category: 'erp',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products'],
supportedDirections: ['import', 'export'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'odoo',
name: 'Odoo',
description: 'Integracion con Odoo ERP (on-premise y cloud)',
category: 'erp',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts', 'payments'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
},
{
type: 'alegra',
name: 'Alegra',
description: 'Integracion con Alegra Contabilidad',
category: 'accounting',
supportedEntities: ['invoices', 'contacts', 'products', 'payments'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
regions: ['MX', 'CO', 'PE', 'AR', 'CL'],
},
{
type: 'sap',
name: 'SAP',
description: 'Integracion con SAP ERP y SAP Business One',
category: 'erp',
supportedEntities: ['transactions', 'invoices', 'contacts', 'products', 'accounts', 'journal_entries'],
supportedDirections: ['import', 'export', 'bidirectional'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: true,
},
{
type: 'sat',
name: 'SAT',
description: 'Servicio de Administracion Tributaria - Descarga de CFDIs',
category: 'fiscal',
supportedEntities: ['cfdis'],
supportedDirections: ['import'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'bank_bbva',
name: 'BBVA Mexico',
description: 'Conexion bancaria BBVA para estados de cuenta',
category: 'bank',
supportedEntities: ['bank_statements'],
supportedDirections: ['import'],
supportsRealtime: false,
supportsWebhooks: false,
isAvailable: true,
isBeta: false,
regions: ['MX'],
},
{
type: 'payments_stripe',
name: 'Stripe',
description: 'Procesador de pagos Stripe',
category: 'payments',
supportedEntities: ['payments', 'invoices'],
supportedDirections: ['import', 'export'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
},
{
type: 'webhook',
name: 'Webhook',
description: 'Webhook personalizado para integraciones custom',
category: 'custom',
supportedEntities: ['transactions', 'invoices', 'contacts'],
supportedDirections: ['import', 'export'],
supportsRealtime: true,
supportsWebhooks: true,
isAvailable: true,
isBeta: false,
},
]);
setConfigured([
{
id: '1',
type: 'sat',
name: 'SAT - ABC123456789',
status: 'active',
isActive: true,
lastSyncAt: '2026-01-31T10:30:00Z',
lastSyncStatus: 'completed',
healthStatus: 'healthy',
provider: {
name: 'SAT',
category: 'fiscal',
},
},
{
id: '2',
type: 'contpaqi',
name: 'CONTPAQi Comercial',
status: 'active',
isActive: true,
lastSyncAt: '2026-01-31T08:00:00Z',
lastSyncStatus: 'completed',
healthStatus: 'healthy',
provider: {
name: 'CONTPAQi',
category: 'erp',
},
},
]);
setIsLoading(false);
};
loadData();
}, []);
// Filtrar providers por categoria
const filteredProviders = selectedCategory === 'all'
? providers
: providers.filter(p => p.category === selectedCategory);
// Categorias unicas
const categories = ['all', ...new Set(providers.map(p => p.category))];
const categoryLabels: Record<string, string> = {
all: 'Todas',
erp: 'ERP',
accounting: 'Contabilidad',
fiscal: 'Fiscal',
bank: 'Bancos',
payments: 'Pagos',
custom: 'Personalizado',
};
// Formatear fecha
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('es-MX', {
dateStyle: 'short',
timeStyle: 'short',
});
};
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Integraciones
</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Conecta tus sistemas externos para sincronizar datos automaticamente
</p>
</div>
<Button
onClick={() => setShowConfigModal(true)}
leftIcon={
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
}
>
Nueva Integracion
</Button>
</div>
{/* Integraciones configuradas */}
{configured.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
Integraciones Configuradas
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{configured.map((integration) => (
<Card
key={integration.id}
variant="default"
hoverable
clickable
onClick={() => setSelectedIntegration(integration)}
className="cursor-pointer"
>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400">
{CategoryIcons[integration.provider?.category || 'custom']}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900 dark:text-white truncate">
{integration.name}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
{integration.provider?.name}
</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-3">
<StatusBadge status={integration.status} />
<HealthBadge status={integration.healthStatus} />
</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p>Ultima sync: {formatDate(integration.lastSyncAt)}</p>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
// Trigger sync
}}
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
>
Sincronizar
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
// Open settings
}}
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
>
Configurar
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
)}
{/* Integraciones disponibles */}
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Integraciones Disponibles
</h2>
{/* Filtros de categoria */}
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
selectedCategory === cat
? 'bg-primary-600 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
}`}
>
{categoryLabels[cat] || cat}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} variant="default" className="animate-pulse">
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-4" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-full mb-2" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-2/3" />
</Card>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredProviders.map((provider) => {
const isConfigured = configured.some(c => c.type === provider.type);
return (
<Card
key={provider.type}
variant="default"
hoverable
className={isConfigured ? 'opacity-60' : ''}
>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">
{CategoryIcons[provider.category]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-900 dark:text-white">
{provider.name}
</h3>
{provider.isBeta && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400 rounded">
Beta
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">
{categoryLabels[provider.category]}
</p>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600 dark:text-slate-300 mb-3">
{provider.description}
</p>
<div className="flex flex-wrap gap-1.5">
{provider.supportsRealtime && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
Tiempo real
</span>
)}
{provider.supportsWebhooks && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Webhooks
</span>
)}
{provider.regions && provider.regions.length > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
{provider.regions.join(', ')}
</span>
)}
</div>
</CardContent>
<CardFooter>
<Button
fullWidth
variant={isConfigured ? 'secondary' : 'primary'}
disabled={isConfigured || !provider.isAvailable}
onClick={() => {
setSelectedProvider(provider);
setShowConfigModal(true);
}}
>
{isConfigured ? 'Ya configurada' : 'Configurar'}
</Button>
</CardFooter>
</Card>
);
})}
</div>
)}
</div>
{/* Modal de configuracion - Placeholder */}
{showConfigModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-lg mx-4">
<CardHeader
title={selectedProvider ? `Configurar ${selectedProvider.name}` : 'Nueva Integracion'}
action={
<button
onClick={() => {
setShowConfigModal(false);
setSelectedProvider(null);
}}
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
}
/>
<CardContent>
<p className="text-slate-600 dark:text-slate-300">
{selectedProvider
? `Configure los parametros de conexion para ${selectedProvider.name}.`
: 'Seleccione una integracion de la lista para configurarla.'}
</p>
{selectedProvider && (
<div className="mt-4 space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
Formulario de configuracion especifico para {selectedProvider.type} proximamente...
</p>
</div>
)}
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setShowConfigModal(false);
setSelectedProvider(null);
}}
>
Cancelar
</Button>
<Button disabled={!selectedProvider}>
Guardar Configuracion
</Button>
</CardFooter>
</Card>
</div>
)}
{/* Modal de detalles de integracion */}
{selectedIntegration && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<CardHeader
title={selectedIntegration.name}
subtitle={`Tipo: ${selectedIntegration.type}`}
action={
<button
onClick={() => setSelectedIntegration(null)}
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
}
/>
<CardContent>
<div className="space-y-6">
{/* Estado */}
<div className="flex items-center gap-4">
<StatusBadge status={selectedIntegration.status} />
<HealthBadge status={selectedIntegration.healthStatus} />
</div>
{/* Ultima sincronizacion */}
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Ultima Sincronizacion
</h4>
<div className="bg-slate-50 dark:bg-slate-800 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500 dark:text-slate-400">Fecha:</span>
<span className="ml-2 text-slate-900 dark:text-white">
{formatDate(selectedIntegration.lastSyncAt)}
</span>
</div>
<div>
<span className="text-slate-500 dark:text-slate-400">Estado:</span>
<span className="ml-2 text-slate-900 dark:text-white capitalize">
{selectedIntegration.lastSyncStatus || '-'}
</span>
</div>
</div>
</div>
</div>
{/* Acciones */}
<div className="flex gap-2">
<Button
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
}
>
Sincronizar Ahora
</Button>
<Button variant="outline">
Ver Historial
</Button>
<Button variant="ghost">
Configurar Schedule
</Button>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="danger"
leftIcon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
>
Eliminar
</Button>
<Button
variant="secondary"
onClick={() => setSelectedIntegration(null)}
>
Cerrar
</Button>
</CardFooter>
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,704 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
ArrowLeft,
Download,
Share2,
Printer,
Calendar,
FileText,
TrendingUp,
TrendingDown,
DollarSign,
PieChart,
BarChart3,
Users,
Receipt,
Mail,
Copy,
Check,
ExternalLink,
} from 'lucide-react';
import { cn, formatDate, formatCurrency, formatPercentage, formatNumber } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import {
ReportSection,
ReportSubSection,
ReportDataRow,
ReportAlert,
ReportChart,
Report,
ReportType,
} from '@/components/reports';
// ============================================================================
// Mock Data
// ============================================================================
const generateMockReportData = (id: string): Report & { fullData: ReportFullData } => {
const startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1, 1);
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 1, 0);
const ingresos = 245000;
const egresos = 180000;
return {
id,
title: `Reporte ${startDate.toLocaleString('es', { month: 'long' })} ${startDate.getFullYear()}`,
type: 'mensual' as ReportType,
status: 'completado',
period: {
start: startDate.toISOString(),
end: endDate.toISOString(),
},
sections: ['Resumen Ejecutivo', 'Ingresos', 'Egresos', 'Flujo de Caja', 'Metricas SaaS', 'Graficas'],
generatedAt: new Date().toISOString(),
fileSize: '2.4MB',
summary: {
ingresos,
egresos,
utilidad: ingresos - egresos,
},
fullData: {
resumenEjecutivo: {
highlights: [
{ label: 'Ingresos Totales', value: ingresos, change: 12.5, format: 'currency' },
{ label: 'Gastos Totales', value: egresos, change: 5.2, format: 'currency' },
{ label: 'Utilidad Neta', value: ingresos - egresos, change: 28.3, format: 'currency' },
{ label: 'Margen Bruto', value: 72.5, change: 3.2, format: 'percentage' },
],
alerts: [
{ type: 'success', title: 'Crecimiento sostenido', message: 'Los ingresos han crecido por 5to mes consecutivo' },
{ type: 'warning', title: 'Cuentas por cobrar', message: '3 facturas vencidas por $15,750 requieren atencion' },
],
},
ingresos: {
total: ingresos,
byCategory: [
{ name: 'Suscripciones', value: 125000, percentage: 51 },
{ name: 'Servicios', value: 75000, percentage: 31 },
{ name: 'Licencias', value: 35000, percentage: 14 },
{ name: 'Otros', value: 10000, percentage: 4 },
],
trend: [
{ name: 'Ene', ingresos: 180000 },
{ name: 'Feb', ingresos: 195000 },
{ name: 'Mar', ingresos: 210000 },
{ name: 'Abr', ingresos: 225000 },
{ name: 'May', ingresos: 235000 },
{ name: 'Jun', ingresos: 245000 },
],
},
egresos: {
total: egresos,
byCategory: [
{ name: 'Nomina', value: 85000, percentage: 47 },
{ name: 'Operaciones', value: 45000, percentage: 25 },
{ name: 'Marketing', value: 25000, percentage: 14 },
{ name: 'Tecnologia', value: 15000, percentage: 8 },
{ name: 'Otros', value: 10000, percentage: 6 },
],
trend: [
{ name: 'Ene', egresos: 150000 },
{ name: 'Feb', egresos: 155000 },
{ name: 'Mar', egresos: 165000 },
{ name: 'Abr', egresos: 170000 },
{ name: 'May', egresos: 175000 },
{ name: 'Jun', egresos: 180000 },
],
},
flujoCaja: {
saldoInicial: 350000,
entradas: 245000,
salidas: 180000,
saldoFinal: 415000,
trend: [
{ name: 'Ene', flujo: 320000 },
{ name: 'Feb', flujo: 345000 },
{ name: 'Mar', flujo: 365000 },
{ name: 'Abr', flujo: 380000 },
{ name: 'May', flujo: 395000 },
{ name: 'Jun', flujo: 415000 },
],
},
metricasSaas: {
mrr: 125000,
arr: 1500000,
churn: 2.5,
ltv: 15000,
cac: 2500,
ltvCac: 6.0,
nrr: 115,
activeCustomers: 342,
newCustomers: 28,
churnedCustomers: 9,
},
},
};
};
interface ReportFullData {
resumenEjecutivo: {
highlights: Array<{ label: string; value: number; change: number; format: 'currency' | 'percentage' | 'number' }>;
alerts: Array<{ type: 'info' | 'success' | 'warning' | 'error'; title: string; message: string }>;
};
ingresos: {
total: number;
byCategory: Array<{ name: string; value: number; percentage: number }>;
trend: Array<{ name: string; ingresos: number }>;
};
egresos: {
total: number;
byCategory: Array<{ name: string; value: number; percentage: number }>;
trend: Array<{ name: string; egresos: number }>;
};
flujoCaja: {
saldoInicial: number;
entradas: number;
salidas: number;
saldoFinal: number;
trend: Array<{ name: string; flujo: number }>;
};
metricasSaas: {
mrr: number;
arr: number;
churn: number;
ltv: number;
cac: number;
ltvCac: number;
nrr: number;
activeCustomers: number;
newCustomers: number;
churnedCustomers: number;
};
}
// ============================================================================
// Loading Skeleton
// ============================================================================
function ReportDetailSkeleton() {
return (
<div className="animate-pulse space-y-6">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
<div className="flex-1">
<div className="h-6 bg-slate-200 dark:bg-slate-700 rounded w-64 mb-2" />
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-48" />
</div>
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<div className="h-5 bg-slate-200 dark:bg-slate-700 rounded w-48 mb-4" />
<div className="space-y-3">
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded" />
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4" />
</div>
</div>
))}
</div>
);
}
// ============================================================================
// Share Modal
// ============================================================================
interface ShareModalProps {
report: Report;
onClose: () => void;
}
function ShareModal({ report, onClose }: ShareModalProps) {
const [email, setEmail] = useState('');
const [copied, setCopied] = useState(false);
const shareUrl = `https://app.horux.io/reportes/${report.id}`;
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleSendEmail = () => {
console.log('Sending email to:', email);
alert(`Reporte enviado a: ${email}`);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full">
<div className="p-5 border-b border-slate-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Compartir Reporte
</h2>
</div>
<div className="p-5 space-y-4">
{/* Copy Link */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Enlace del reporte
</label>
<div className="flex gap-2">
<input
type="text"
value={shareUrl}
readOnly
className="flex-1 px-3 py-2 bg-slate-50 dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
<Button
variant="secondary"
onClick={handleCopyLink}
leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? 'Copiado' : 'Copiar'}
</Button>
</div>
</div>
{/* Send Email */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Enviar por email
</label>
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="correo@ejemplo.com"
className="flex-1 px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
<Button
variant="primary"
onClick={handleSendEmail}
disabled={!email}
leftIcon={<Mail className="w-4 h-4" />}
>
Enviar
</Button>
</div>
</div>
</div>
<div className="p-5 border-t border-slate-200 dark:border-slate-700 flex justify-end">
<Button variant="outline" onClick={onClose}>
Cerrar
</Button>
</div>
</div>
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function ReporteDetailPage() {
const router = useRouter();
const params = useParams();
const reportId = params.id as string;
const [report, setReport] = useState<(Report & { fullData: ReportFullData }) | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showShareModal, setShowShareModal] = useState(false);
const fetchReport = useCallback(async () => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 600));
setReport(generateMockReportData(reportId));
} catch (error) {
console.error('Error fetching report:', error);
} finally {
setIsLoading(false);
}
}, [reportId]);
useEffect(() => {
fetchReport();
}, [fetchReport]);
const handleDownload = () => {
console.log('Downloading report:', reportId);
alert('Descargando reporte...');
};
const handlePrint = () => {
window.print();
};
const formatValue = (value: number, format: 'currency' | 'percentage' | 'number') => {
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
default:
return formatNumber(value);
}
};
if (isLoading) {
return <ReportDetailSkeleton />;
}
if (!report) {
return (
<div className="text-center py-12">
<FileText className="w-12 h-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" />
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
Reporte no encontrado
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-4">
El reporte que buscas no existe o ha sido eliminado.
</p>
<Button onClick={() => router.push('/reportes')}>
Volver a Reportes
</Button>
</div>
);
}
const { fullData } = report;
return (
<div className="space-y-6 print:space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 print:hidden">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/reportes')}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{report.title}
</h1>
<p className="text-slate-500 dark:text-slate-400 flex items-center gap-2">
<Calendar className="w-4 h-4" />
{formatDate(report.period.start)} - {formatDate(report.period.end)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
leftIcon={<Printer className="w-4 h-4" />}
onClick={handlePrint}
>
Imprimir
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<Share2 className="w-4 h-4" />}
onClick={() => setShowShareModal(true)}
>
Compartir
</Button>
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={handleDownload}
>
Descargar PDF
</Button>
</div>
</div>
{/* Print Header */}
<div className="hidden print:block">
<h1 className="text-2xl font-bold">{report.title}</h1>
<p className="text-slate-500">
Periodo: {formatDate(report.period.start)} - {formatDate(report.period.end)}
</p>
</div>
{/* Resumen Ejecutivo */}
<ReportSection
title="Resumen Ejecutivo"
icon={<FileText className="w-5 h-5" />}
badge="Destacados"
>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{fullData.resumenEjecutivo.highlights.map((item) => (
<div
key={item.label}
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50"
>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-1">
{item.label}
</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">
{formatValue(item.value, item.format)}
</p>
<p
className={cn(
'text-sm flex items-center gap-1 mt-1',
item.change >= 0
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'
)}
>
{item.change >= 0 ? (
<TrendingUp className="w-3.5 h-3.5" />
) : (
<TrendingDown className="w-3.5 h-3.5" />
)}
{formatPercentage(Math.abs(item.change))}
</p>
</div>
))}
</div>
{/* Alerts */}
<div className="space-y-3">
{fullData.resumenEjecutivo.alerts.map((alert, index) => (
<ReportAlert
key={index}
type={alert.type}
title={alert.title}
message={alert.message}
/>
))}
</div>
</ReportSection>
{/* Ingresos */}
<ReportSection
title="Analisis de Ingresos"
icon={<TrendingUp className="w-5 h-5" />}
badge={formatCurrency(fullData.ingresos.total)}
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* By Category */}
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
Desglose por Categoria
</h4>
<div className="space-y-3">
{fullData.ingresos.byCategory.map((cat) => (
<div key={cat.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-700 dark:text-slate-300">{cat.name}</span>
<span className="font-medium text-slate-900 dark:text-white">
{formatCurrency(cat.value)} ({cat.percentage}%)
</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-success-500 rounded-full"
style={{ width: `${cat.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Trend Chart */}
<ReportChart
title="Tendencia de Ingresos"
type="area"
data={fullData.ingresos.trend}
series={[{ dataKey: 'ingresos', name: 'Ingresos', color: '#10b981' }]}
formatTooltip="currency"
height={250}
showLegend={false}
/>
</div>
</ReportSection>
{/* Egresos */}
<ReportSection
title="Analisis de Egresos"
icon={<Receipt className="w-5 h-5" />}
badge={formatCurrency(fullData.egresos.total)}
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* By Category */}
<div>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
Desglose por Categoria
</h4>
<div className="space-y-3">
{fullData.egresos.byCategory.map((cat) => (
<div key={cat.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-700 dark:text-slate-300">{cat.name}</span>
<span className="font-medium text-slate-900 dark:text-white">
{formatCurrency(cat.value)} ({cat.percentage}%)
</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-error-500 rounded-full"
style={{ width: `${cat.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Trend Chart */}
<ReportChart
title="Tendencia de Egresos"
type="bar"
data={fullData.egresos.trend}
series={[{ dataKey: 'egresos', name: 'Egresos', color: '#ef4444' }]}
formatTooltip="currency"
height={250}
showLegend={false}
/>
</div>
</ReportSection>
{/* Flujo de Caja */}
<ReportSection
title="Flujo de Caja"
icon={<DollarSign className="w-5 h-5" />}
>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Saldo Inicial</p>
<p className="text-xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.flujoCaja.saldoInicial)}
</p>
</div>
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Entradas</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400 mt-1">
+{formatCurrency(fullData.flujoCaja.entradas)}
</p>
</div>
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Salidas</p>
<p className="text-xl font-bold text-error-600 dark:text-error-400 mt-1">
-{formatCurrency(fullData.flujoCaja.salidas)}
</p>
</div>
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Saldo Final</p>
<p className="text-xl font-bold text-primary-600 dark:text-primary-400 mt-1">
{formatCurrency(fullData.flujoCaja.saldoFinal)}
</p>
</div>
</div>
<ReportChart
title="Evolucion del Flujo de Caja"
type="line"
data={fullData.flujoCaja.trend}
series={[{ dataKey: 'flujo', name: 'Saldo', color: '#0c8ce8' }]}
formatTooltip="currency"
height={250}
/>
</ReportSection>
{/* Metricas SaaS */}
<ReportSection
title="Metricas SaaS"
icon={<BarChart3 className="w-5 h-5" />}
>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">MRR</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.mrr)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">ARR</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.arr)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">Churn Rate</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{fullData.metricasSaas.churn}%
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">NRR</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{fullData.metricasSaas.nrr}%
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">LTV</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.ltv)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">CAC</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{formatCurrency(fullData.metricasSaas.cac)}
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">LTV/CAC</p>
<p className="text-2xl font-bold text-success-600 dark:text-success-400 mt-1">
{fullData.metricasSaas.ltvCac}x
</p>
</div>
<div className="p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-500 dark:text-slate-400">Clientes Activos</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white mt-1">
{fullData.metricasSaas.activeCustomers}
</p>
</div>
</div>
<ReportSubSection title="Movimiento de Clientes">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Nuevos</p>
<p className="text-2xl font-bold text-success-600 dark:text-success-400">
+{fullData.metricasSaas.newCustomers}
</p>
</div>
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Cancelados</p>
<p className="text-2xl font-bold text-error-600 dark:text-error-400">
-{fullData.metricasSaas.churnedCustomers}
</p>
</div>
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Crecimiento Neto</p>
<p className="text-2xl font-bold text-primary-600 dark:text-primary-400">
+{fullData.metricasSaas.newCustomers - fullData.metricasSaas.churnedCustomers}
</p>
</div>
</div>
</ReportSubSection>
</ReportSection>
{/* Footer */}
<div className="text-center py-6 text-sm text-slate-500 dark:text-slate-400 print:hidden">
<p>Generado el {formatDate(report.generatedAt || new Date().toISOString())}</p>
<p className="mt-1">Horux Strategy - CFO Digital</p>
</div>
{/* Share Modal */}
{showShareModal && (
<ShareModal report={report} onClose={() => setShowShareModal(false)} />
)}
</div>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, FileText, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { GenerateReportWizard, ReportConfig } from '@/components/reports';
import { cn } from '@/lib/utils';
/**
* Pagina para generar un nuevo reporte
*
* Presenta un wizard de 3 pasos para configurar y generar
* un nuevo reporte financiero.
*/
export default function NuevoReportePage() {
const router = useRouter();
const [isComplete, setIsComplete] = useState(false);
const [generatedReportId, setGeneratedReportId] = useState<string | null>(null);
const handleComplete = async (config: ReportConfig) => {
console.log('Generating report with config:', config);
// Simular generacion del reporte
await new Promise((resolve) => setTimeout(resolve, 2000));
// Simular ID del reporte generado
const reportId = `report-${Date.now()}`;
setGeneratedReportId(reportId);
setIsComplete(true);
};
const handleCancel = () => {
router.push('/reportes');
};
const handleViewReport = () => {
if (generatedReportId) {
router.push(`/reportes/${generatedReportId}`);
}
};
const handleGenerateAnother = () => {
setIsComplete(false);
setGeneratedReportId(null);
};
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/reportes')}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Generar Nuevo Reporte
</h1>
<p className="text-slate-500 dark:text-slate-400">
Configura y genera un reporte financiero personalizado
</p>
</div>
</div>
{/* Content */}
{isComplete ? (
// Success State
<Card className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
<CheckCircle className="w-10 h-10 text-success-600 dark:text-success-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
Reporte Generado Exitosamente
</h2>
<p className="text-slate-500 dark:text-slate-400 max-w-md mx-auto mb-8">
Tu reporte ha sido generado y esta listo para visualizarse.
Puedes verlo ahora o generar otro reporte.
</p>
<div className="flex items-center justify-center gap-4">
<Button variant="outline" onClick={handleGenerateAnother}>
Generar Otro Reporte
</Button>
<Button onClick={handleViewReport} leftIcon={<FileText className="w-4 h-4" />}>
Ver Reporte
</Button>
</div>
</Card>
) : (
// Wizard
<GenerateReportWizard
onComplete={handleComplete}
onCancel={handleCancel}
className="border border-slate-200 dark:border-slate-700 shadow-lg"
/>
)}
{/* Tips Section */}
{!isComplete && (
<Card className="bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800">
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="p-2 rounded-lg bg-primary-100 dark:bg-primary-900/40">
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
</div>
<div>
<h3 className="font-semibold text-primary-900 dark:text-primary-100 mb-1">
Consejos para generar reportes
</h3>
<ul className="text-sm text-primary-700 dark:text-primary-300 space-y-1">
<li>- Selecciona el tipo de reporte que mejor se ajuste a tus necesidades de analisis</li>
<li>- Los reportes mensuales son ideales para seguimiento operativo</li>
<li>- Los reportes trimestrales ofrecen mejor perspectiva de tendencias</li>
<li>- Incluye las secciones de metricas SaaS si tienes un modelo de suscripcion</li>
</ul>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,685 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText,
Plus,
Search,
Filter,
Download,
RefreshCw,
Calendar,
Grid3X3,
List,
ChevronLeft,
ChevronRight,
Eye,
Clock,
CheckCircle,
X,
} from 'lucide-react';
import { cn, formatDate, formatCurrency } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { ReportCard, Report, ReportType, ReportStatus } from '@/components/reports';
// ============================================================================
// Types
// ============================================================================
interface Filters {
search: string;
type: ReportType | 'all';
status: ReportStatus | 'all';
dateFrom: string;
dateTo: string;
}
type ViewMode = 'grid' | 'list';
// ============================================================================
// Mock Data
// ============================================================================
const generateMockReports = (): Report[] => {
const reports: Report[] = [];
const types: ReportType[] = ['mensual', 'trimestral', 'anual', 'custom'];
const statuses: ReportStatus[] = ['completado', 'completado', 'completado', 'generando', 'pendiente'];
const sectionOptions = [
'Resumen Ejecutivo',
'Ingresos',
'Egresos',
'Flujo de Caja',
'Metricas SaaS',
'Cuentas por Cobrar',
'Cuentas por Pagar',
'Graficas',
];
for (let i = 0; i < 12; i++) {
const type = types[i % types.length];
const status = statuses[Math.floor(Math.random() * statuses.length)];
const date = new Date();
date.setMonth(date.getMonth() - i);
const startDate = new Date(date.getFullYear(), date.getMonth(), 1);
let endDate: Date;
switch (type) {
case 'trimestral':
endDate = new Date(date.getFullYear(), date.getMonth() + 3, 0);
break;
case 'anual':
endDate = new Date(date.getFullYear(), 11, 31);
startDate.setMonth(0);
break;
default:
endDate = new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
const ingresos = Math.floor(Math.random() * 500000) + 100000;
const egresos = Math.floor(ingresos * (0.5 + Math.random() * 0.3));
reports.push({
id: `report-${i + 1}`,
title: type === 'anual'
? `Reporte Anual ${startDate.getFullYear()}`
: type === 'trimestral'
? `Reporte Q${Math.ceil((startDate.getMonth() + 1) / 3)} ${startDate.getFullYear()}`
: type === 'custom'
? `Reporte Personalizado ${i + 1}`
: `Reporte ${startDate.toLocaleString('es', { month: 'long' })} ${startDate.getFullYear()}`,
type,
status,
period: {
start: startDate.toISOString(),
end: endDate.toISOString(),
},
sections: sectionOptions.slice(0, Math.floor(Math.random() * 4) + 4),
generatedAt: status === 'completado' ? date.toISOString() : undefined,
fileSize: status === 'completado' ? `${Math.floor(Math.random() * 5) + 1}.${Math.floor(Math.random() * 9)}MB` : undefined,
summary: status === 'completado' ? {
ingresos,
egresos,
utilidad: ingresos - egresos,
} : undefined,
progress: status === 'generando' ? Math.floor(Math.random() * 80) + 10 : undefined,
});
}
return reports;
};
// ============================================================================
// Loading Skeleton
// ============================================================================
function ReportsSkeleton() {
return (
<div className="animate-pulse space-y-6">
<div className="flex items-center justify-between">
<div className="h-8 bg-slate-200 dark:bg-slate-700 rounded w-48" />
<div className="h-10 bg-slate-200 dark:bg-slate-700 rounded w-40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg" />
<div className="flex-1">
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2" />
</div>
</div>
<div className="space-y-3">
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded" />
<div className="h-3 bg-slate-200 dark:bg-slate-700 rounded w-2/3" />
</div>
</div>
))}
</div>
</div>
);
}
// ============================================================================
// Filter Panel
// ============================================================================
interface FilterPanelProps {
filters: Filters;
onChange: (filters: Filters) => void;
onClose: () => void;
isOpen: boolean;
}
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
if (!isOpen) return null;
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 shadow-lg">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 dark:text-white">Filtros</h3>
<button onClick={onClose} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Tipo de Reporte
</label>
<select
value={filters.type}
onChange={(e) => onChange({ ...filters, type: e.target.value as Filters['type'] })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="mensual">Mensual</option>
<option value="trimestral">Trimestral</option>
<option value="anual">Anual</option>
<option value="custom">Personalizado</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Estado
</label>
<select
value={filters.status}
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
>
<option value="all">Todos</option>
<option value="completado">Completado</option>
<option value="generando">Generando</option>
<option value="pendiente">Pendiente</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Desde
</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Hasta
</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
<button
onClick={() => onChange({ search: '', type: 'all', status: 'all', dateFrom: '', dateTo: '' })}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
Limpiar filtros
</button>
<Button size="sm" onClick={onClose}>
Aplicar
</Button>
</div>
</div>
);
}
// ============================================================================
// Report Preview Modal
// ============================================================================
interface ReportPreviewProps {
report: Report;
onClose: () => void;
onView: () => void;
onDownload: () => void;
}
function ReportPreview({ report, onClose, onView, onDownload }: ReportPreviewProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-primary-100 dark:bg-primary-900/30">
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h2 className="font-semibold text-slate-900 dark:text-white">{report.title}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{formatDate(report.period.start)} - {formatDate(report.period.end)}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-5 space-y-6 max-h-[60vh] overflow-y-auto">
{/* Summary */}
{report.summary && (
<div>
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Resumen Financiero
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-success-50 dark:bg-success-900/20 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Ingresos</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400">
{formatCurrency(report.summary.ingresos)}
</p>
</div>
<div className="p-4 rounded-lg bg-error-50 dark:bg-error-900/20 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Egresos</p>
<p className="text-xl font-bold text-error-600 dark:text-error-400">
{formatCurrency(report.summary.egresos)}
</p>
</div>
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">Utilidad</p>
<p className="text-xl font-bold text-primary-600 dark:text-primary-400">
{formatCurrency(report.summary.utilidad)}
</p>
</div>
</div>
</div>
)}
{/* Sections */}
<div>
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Secciones Incluidas
</h3>
<div className="flex flex-wrap gap-2">
{report.sections.map((section) => (
<span
key={section}
className="px-3 py-1.5 text-sm rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"
>
{section}
</span>
))}
</div>
</div>
{/* Details */}
<div>
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Detalles
</h3>
<dl className="space-y-2">
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
<dt className="text-sm text-slate-500 dark:text-slate-400">Tipo</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white capitalize">{report.type}</dd>
</div>
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
<dt className="text-sm text-slate-500 dark:text-slate-400">Estado</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white capitalize">{report.status}</dd>
</div>
{report.generatedAt && (
<div className="flex justify-between py-2 border-b border-slate-100 dark:border-slate-700">
<dt className="text-sm text-slate-500 dark:text-slate-400">Generado</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white">
{formatDate(report.generatedAt)}
</dd>
</div>
)}
{report.fileSize && (
<div className="flex justify-between py-2">
<dt className="text-sm text-slate-500 dark:text-slate-400">Tamano</dt>
<dd className="text-sm font-medium text-slate-900 dark:text-white">{report.fileSize}</dd>
</div>
)}
</dl>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-slate-200 dark:border-slate-700">
<Button variant="outline" onClick={onClose}>
Cerrar
</Button>
{report.status === 'completado' && (
<>
<Button variant="secondary" leftIcon={<Download className="w-4 h-4" />} onClick={onDownload}>
Descargar PDF
</Button>
<Button leftIcon={<Eye className="w-4 h-4" />} onClick={onView}>
Ver Completo
</Button>
</>
)}
</div>
</div>
</div>
);
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function ReportesPage() {
const router = useRouter();
const [reports, setReports] = useState<Report[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [previewReport, setPreviewReport] = useState<Report | null>(null);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Filters>({
search: '',
type: 'all',
status: 'all',
dateFrom: '',
dateTo: '',
});
const limit = 9;
const fetchReports = useCallback(async () => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 600));
setReports(generateMockReports());
} catch (error) {
console.error('Error fetching reports:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchReports();
}, [fetchReports]);
// Filter reports
const filteredReports = useMemo(() => {
return reports.filter((report) => {
if (filters.search) {
const searchLower = filters.search.toLowerCase();
if (!report.title.toLowerCase().includes(searchLower)) {
return false;
}
}
if (filters.type !== 'all' && report.type !== filters.type) return false;
if (filters.status !== 'all' && report.status !== filters.status) return false;
if (filters.dateFrom && report.period.start < filters.dateFrom) return false;
if (filters.dateTo && report.period.end > filters.dateTo) return false;
return true;
});
}, [reports, filters]);
const paginatedReports = useMemo(() => {
const start = (page - 1) * limit;
return filteredReports.slice(start, start + limit);
}, [filteredReports, page]);
const totalPages = Math.ceil(filteredReports.length / limit);
// Summary stats
const stats = useMemo(() => ({
total: reports.length,
completados: reports.filter(r => r.status === 'completado').length,
generando: reports.filter(r => r.status === 'generando').length,
pendientes: reports.filter(r => r.status === 'pendiente').length,
}), [reports]);
const handleViewReport = (report: Report) => {
router.push(`/reportes/${report.id}`);
};
const handleDownloadReport = (report: Report) => {
// Simular descarga
console.log('Downloading report:', report.id);
alert(`Descargando: ${report.title}`);
};
if (isLoading) {
return (
<div className="space-y-6">
<ReportsSkeleton />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
Reportes
</h1>
<p className="mt-1 text-slate-500 dark:text-slate-400">
Gestiona y genera reportes financieros
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
leftIcon={<RefreshCw className="w-4 h-4" />}
onClick={fetchReports}
>
Actualizar
</Button>
<Button
size="sm"
leftIcon={<Plus className="w-4 h-4" />}
onClick={() => router.push('/reportes/nuevo')}
>
Generar Reporte
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-slate-100 dark:bg-slate-700">
<FileText className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Total</p>
<p className="text-xl font-bold text-slate-900 dark:text-white">{stats.total}</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-success-100 dark:bg-success-900/30">
<CheckCircle className="w-5 h-5 text-success-600 dark:text-success-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Completados</p>
<p className="text-xl font-bold text-success-600 dark:text-success-400">{stats.completados}</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-primary-100 dark:bg-primary-900/30">
<Clock className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Generando</p>
<p className="text-xl font-bold text-primary-600 dark:text-primary-400">{stats.generando}</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-warning-100 dark:bg-warning-900/30">
<Calendar className="w-5 h-5 text-warning-600 dark:text-warning-400" />
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Pendientes</p>
<p className="text-xl font-bold text-warning-600 dark:text-warning-400">{stats.pendientes}</p>
</div>
</div>
</Card>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Buscar reportes..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
'flex items-center gap-2 px-4 py-2.5 border rounded-lg transition-colors',
showFilters
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline">Filtros</span>
</button>
<div className="flex items-center border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={cn(
'p-2.5',
viewMode === 'grid'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={cn(
'p-2.5',
viewMode === 'list'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Filter Panel */}
{showFilters && (
<FilterPanel
filters={filters}
onChange={setFilters}
onClose={() => setShowFilters(false)}
isOpen={showFilters}
/>
)}
{/* Reports Grid/List */}
{paginatedReports.length > 0 ? (
<div
className={cn(
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'space-y-4'
)}
>
{paginatedReports.map((report) => (
<ReportCard
key={report.id}
report={report}
onView={(r) => setPreviewReport(r)}
onDownload={handleDownloadReport}
/>
))}
</div>
) : (
<Card className="text-center py-12">
<FileText className="w-12 h-12 mx-auto text-slate-300 dark:text-slate-600 mb-4" />
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
No se encontraron reportes
</h3>
<p className="text-slate-500 dark:text-slate-400 mb-4">
Ajusta los filtros o genera un nuevo reporte
</p>
<Button onClick={() => router.push('/reportes/nuevo')}>
Generar Reporte
</Button>
</Card>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredReports.length)} de {filteredReports.length} reportes
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-slate-600 dark:text-slate-400">
Pagina {page} de {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
)}
{/* Preview Modal */}
{previewReport && (
<ReportPreview
report={previewReport}
onClose={() => setPreviewReport(null)}
onView={() => {
setPreviewReport(null);
handleViewReport(previewReport);
}}
onDownload={() => handleDownloadReport(previewReport)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,338 @@
'use client';
import React from 'react';
import { cn, formatCurrency, formatPercentage } from '@/lib/utils';
import {
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle,
Info,
Lightbulb,
ArrowRight,
Sparkles,
} from 'lucide-react';
/**
* Tipo de insight
*/
export type InsightType = 'positive' | 'negative' | 'warning' | 'info' | 'suggestion';
/**
* Prioridad del insight
*/
export type InsightPriority = 'high' | 'medium' | 'low';
/**
* Interface del insight
*/
export interface AIInsight {
id: string;
type: InsightType;
priority: InsightPriority;
title: string;
description: string;
metric?: {
label: string;
value: number | string;
change?: number;
format?: 'currency' | 'percentage' | 'number';
};
action?: {
label: string;
href?: string;
onClick?: () => void;
};
timestamp: Date;
}
/**
* Props del componente AIInsightCard
*/
interface AIInsightCardProps {
insight: AIInsight;
onAction?: () => void;
onDismiss?: () => void;
className?: string;
compact?: boolean;
}
/**
* Estilos por tipo de insight
*/
const typeStyles: Record<InsightType, {
bg: string;
border: string;
icon: React.ReactNode;
iconBg: string;
iconColor: string;
}> = {
positive: {
bg: 'bg-success-50 dark:bg-success-900/20',
border: 'border-success-200 dark:border-success-800',
icon: <TrendingUp className="w-5 h-5" />,
iconBg: 'bg-success-100 dark:bg-success-900/40',
iconColor: 'text-success-600 dark:text-success-400',
},
negative: {
bg: 'bg-error-50 dark:bg-error-900/20',
border: 'border-error-200 dark:border-error-800',
icon: <TrendingDown className="w-5 h-5" />,
iconBg: 'bg-error-100 dark:bg-error-900/40',
iconColor: 'text-error-600 dark:text-error-400',
},
warning: {
bg: 'bg-warning-50 dark:bg-warning-900/20',
border: 'border-warning-200 dark:border-warning-800',
icon: <AlertTriangle className="w-5 h-5" />,
iconBg: 'bg-warning-100 dark:bg-warning-900/40',
iconColor: 'text-warning-600 dark:text-warning-400',
},
info: {
bg: 'bg-primary-50 dark:bg-primary-900/20',
border: 'border-primary-200 dark:border-primary-800',
icon: <Info className="w-5 h-5" />,
iconBg: 'bg-primary-100 dark:bg-primary-900/40',
iconColor: 'text-primary-600 dark:text-primary-400',
},
suggestion: {
bg: 'bg-violet-50 dark:bg-violet-900/20',
border: 'border-violet-200 dark:border-violet-800',
icon: <Lightbulb className="w-5 h-5" />,
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
iconColor: 'text-violet-600 dark:text-violet-400',
},
};
/**
* Badge de prioridad
*/
const priorityBadge: Record<InsightPriority, { label: string; className: string }> = {
high: {
label: 'Alta prioridad',
className: 'bg-error-100 text-error-700 dark:bg-error-900/40 dark:text-error-400',
},
medium: {
label: 'Media',
className: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-400',
},
low: {
label: 'Baja',
className: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-400',
},
};
/**
* Formatear valor de metrica
*/
const formatMetricValue = (value: number | string, format?: 'currency' | 'percentage' | 'number'): string => {
if (typeof value === 'string') return value;
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
case 'number':
default:
return value.toLocaleString('es-ES');
}
};
/**
* Componente AIInsightCard
*
* Tarjeta que muestra un insight generado por IA con metricas
* relevantes y acciones sugeridas.
*/
export const AIInsightCard: React.FC<AIInsightCardProps> = ({
insight,
onAction,
onDismiss,
className,
compact = false,
}) => {
const styles = typeStyles[insight.type];
const priority = priorityBadge[insight.priority];
if (compact) {
return (
<div
className={cn(
'flex items-start gap-3 p-3 rounded-lg border',
styles.bg,
styles.border,
className
)}
>
<span className={cn('flex-shrink-0 p-1.5 rounded-md', styles.iconBg, styles.iconColor)}>
{styles.icon}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-white line-clamp-1">
{insight.title}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 mt-0.5">
{insight.description}
</p>
</div>
{insight.action && (
<button
onClick={insight.action.onClick || onAction}
className={cn('flex-shrink-0 p-1 rounded', styles.iconColor, 'hover:bg-white/50 dark:hover:bg-slate-800/50')}
>
<ArrowRight className="w-4 h-4" />
</button>
)}
</div>
);
}
return (
<div
className={cn(
'rounded-xl border overflow-hidden',
styles.bg,
styles.border,
className
)}
>
{/* Header */}
<div className="p-4 flex items-start gap-3">
<span className={cn('flex-shrink-0 p-2.5 rounded-lg', styles.iconBg, styles.iconColor)}>
{styles.icon}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-slate-900 dark:text-white">
{insight.title}
</h3>
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', priority.className)}>
{priority.label}
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
{insight.description}
</p>
</div>
<div className="flex items-center gap-1">
<Sparkles className="w-4 h-4 text-violet-500" />
<span className="text-xs text-slate-400 dark:text-slate-500">AI</span>
</div>
</div>
{/* Metric */}
{insight.metric && (
<div className="px-4 pb-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-white/50 dark:bg-slate-800/50">
<span className="text-sm text-slate-600 dark:text-slate-400">
{insight.metric.label}
</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-slate-900 dark:text-white">
{formatMetricValue(insight.metric.value, insight.metric.format)}
</span>
{insight.metric.change !== undefined && (
<span
className={cn(
'flex items-center gap-0.5 text-sm font-medium',
insight.metric.change >= 0
? 'text-success-600 dark:text-success-400'
: 'text-error-600 dark:text-error-400'
)}
>
{insight.metric.change >= 0 ? (
<TrendingUp className="w-3.5 h-3.5" />
) : (
<TrendingDown className="w-3.5 h-3.5" />
)}
{formatPercentage(Math.abs(insight.metric.change))}
</span>
)}
</div>
</div>
</div>
)}
{/* Action */}
{insight.action && (
<div className="px-4 pb-4">
<button
onClick={insight.action.onClick || onAction}
className={cn(
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg',
'text-sm font-medium transition-colors',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'border border-slate-200 dark:border-slate-700',
'hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
{insight.action.label}
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
};
/**
* Componente InsightsList para mostrar multiples insights
*/
interface InsightsListProps {
insights: AIInsight[];
onInsightAction?: (insight: AIInsight) => void;
className?: string;
title?: string;
maxVisible?: number;
}
export const InsightsList: React.FC<InsightsListProps> = ({
insights,
onInsightAction,
className,
title = 'Insights de IA',
maxVisible = 5,
}) => {
const [showAll, setShowAll] = React.useState(false);
const visibleInsights = showAll ? insights : insights.slice(0, maxVisible);
const hasMore = insights.length > maxVisible;
return (
<div className={cn('space-y-4', className)}>
{title && (
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-white flex items-center gap-2">
<Sparkles className="w-5 h-5 text-violet-500" />
{title}
</h3>
<span className="text-sm text-slate-500 dark:text-slate-400">
{insights.length} insights
</span>
</div>
)}
<div className="space-y-3">
{visibleInsights.map((insight) => (
<AIInsightCard
key={insight.id}
insight={insight}
compact
onAction={() => onInsightAction?.(insight)}
/>
))}
</div>
{hasMore && (
<button
onClick={() => setShowAll(!showAll)}
className="w-full py-2 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
>
{showAll ? 'Mostrar menos' : `Ver ${insights.length - maxVisible} mas`}
</button>
)}
</div>
);
};
export default AIInsightCard;

View File

@@ -0,0 +1,458 @@
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { ChatMessage, Message, MessageRole } from './ChatMessage';
import { SuggestedQuestions, defaultSuggestedQuestions, QuickActions, SuggestedQuestion } from './SuggestedQuestions';
import {
Send,
Mic,
Paperclip,
Settings,
Trash2,
Bot,
Sparkles,
StopCircle,
Menu,
} from 'lucide-react';
/**
* Props del componente ChatInterface
*/
interface ChatInterfaceProps {
onSendMessage?: (message: string) => Promise<void>;
initialMessages?: Message[];
suggestedQuestions?: SuggestedQuestion[];
isLoading?: boolean;
className?: string;
showSidebar?: boolean;
}
/**
* Generar ID unico
*/
const generateId = () => Math.random().toString(36).substring(2) + Date.now().toString(36);
/**
* Componente ChatInterface
*
* Interface de chat completa para el asistente CFO Digital
* con soporte para streaming, sugerencias y contenido enriquecido.
*/
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
onSendMessage,
initialMessages = [],
suggestedQuestions = defaultSuggestedQuestions,
isLoading = false,
className,
showSidebar = true,
}) => {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [inputValue, setInputValue] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(showSidebar);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll al final de los mensajes
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Auto-resize del textarea
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInputValue(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
};
// Enviar mensaje
const handleSendMessage = async () => {
const trimmedInput = inputValue.trim();
if (!trimmedInput || isLoading || isStreaming) return;
// Agregar mensaje del usuario
const userMessage: Message = {
id: generateId(),
role: 'user',
content: trimmedInput,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
// Simular respuesta del asistente
setIsStreaming(true);
const assistantMessage: Message = {
id: generateId(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
};
setMessages((prev) => [...prev, assistantMessage]);
try {
// Si hay un handler externo, usarlo
if (onSendMessage) {
await onSendMessage(trimmedInput);
} else {
// Simular respuesta con streaming
await simulateStreaming(assistantMessage.id, trimmedInput);
}
} catch (error) {
console.error('Error sending message:', error);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, content: 'Lo siento, hubo un error procesando tu consulta. Por favor intenta de nuevo.', isStreaming: false, isError: true }
: msg
)
);
} finally {
setIsStreaming(false);
}
};
// Simular streaming de respuesta
const simulateStreaming = async (messageId: string, userQuery: string) => {
const responses: Record<string, { content: string; inlineContent?: Message['inlineContent'] }> = {
'flujo de caja': {
content: 'Analizando tu flujo de caja para este mes...\n\n**Resumen del Flujo de Caja:**\n\nTu flujo de caja neto es positivo este mes. Los ingresos operativos superan los gastos, lo cual es una excelente senal.\n\n**Recomendacion:** Considera destinar el excedente a una reserva de emergencia o a prepagar deuda de alto interes.',
inlineContent: [
{
type: 'metric',
data: { label: 'Flujo de Caja Neto', value: '$45,230', change: '+12.5% vs mes anterior' },
},
{
type: 'table',
data: {
headers: ['Concepto', 'Monto'],
rows: [
['Ingresos Operativos', '$125,000'],
['Gastos Operativos', '$79,770'],
['Flujo Neto', '$45,230'],
],
},
},
],
},
'metricas': {
content: 'Aqui estan tus **metricas SaaS clave** actualizadas:\n\n- **MRR (Monthly Recurring Revenue):** Crecimiento sostenido del 5.9% mensual\n- **ARR (Annual Recurring Revenue):** Proyeccion solida para el ano\n- **Churn Rate:** Por debajo del promedio de la industria\n- **LTV/CAC Ratio:** Excelente eficiencia en adquisicion\n\nTu negocio muestra indicadores saludables de crecimiento sostenible.',
inlineContent: [
{
type: 'metric',
data: { label: 'MRR', value: '$125,000', change: '+5.9% vs mes anterior' },
},
{
type: 'metric',
data: { label: 'LTV/CAC', value: '6.0x', change: '+25% YoY' },
},
],
},
'facturas vencidas': {
content: 'Analizando las cuentas por cobrar...\n\nTienes **3 clientes** con facturas vencidas por un total de **$15,750**.\n\n**Accion recomendada:** Te sugiero enviar recordatorios automaticos y considerar ofrecer un pequeno descuento por pronto pago.',
inlineContent: [
{
type: 'alert',
data: { type: 'warning', title: 'Atencion', message: '3 facturas vencidas requieren seguimiento inmediato' },
},
{
type: 'table',
data: {
headers: ['Cliente', 'Monto', 'Dias Vencido'],
rows: [
['Tech Solutions', '$8,500', '15'],
['Servicios Beta', '$4,250', '8'],
['Cliente Uno', '$3,000', '5'],
],
},
},
],
},
'default': {
content: 'Entiendo tu consulta. Dejame analizar la informacion disponible...\n\nBasandome en los datos de tu empresa, puedo darte las siguientes recomendaciones:\n\n1. **Optimizacion de costos:** Revisa los gastos recurrentes\n2. **Mejora de cobranza:** Implementa recordatorios automaticos\n3. **Diversificacion de ingresos:** Explora nuevas lineas de productos\n\nQuieres que profundice en alguno de estos puntos?',
},
};
// Determinar respuesta basada en query
let responseData = responses['default'];
const queryLower = userQuery.toLowerCase();
if (queryLower.includes('flujo') || queryLower.includes('caja')) {
responseData = responses['flujo de caja'];
} else if (queryLower.includes('metrica') || queryLower.includes('mrr') || queryLower.includes('saas')) {
responseData = responses['metricas'];
} else if (queryLower.includes('vencid') || queryLower.includes('factura') || queryLower.includes('cobrar')) {
responseData = responses['facturas vencidas'];
}
// Simular streaming caracter por caracter
const fullContent = responseData.content;
let currentContent = '';
for (let i = 0; i < fullContent.length; i++) {
await new Promise((resolve) => setTimeout(resolve, 15 + Math.random() * 10));
currentContent += fullContent[i];
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? { ...msg, content: currentContent }
: msg
)
);
}
// Agregar contenido inline al final
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? { ...msg, isStreaming: false, inlineContent: responseData.inlineContent }
: msg
)
);
};
// Manejar seleccion de pregunta sugerida
const handleSuggestedQuestion = (question: SuggestedQuestion) => {
setInputValue(question.text);
inputRef.current?.focus();
};
// Manejar accion rapida
const handleQuickAction = (action: string) => {
const actionQueries: Record<string, string> = {
resumen_diario: 'Dame un resumen ejecutivo del dia de hoy',
metricas_clave: 'Cuales son mis metricas clave actuales?',
alertas_activas: 'Hay alguna alerta financiera activa?',
proyeccion_mes: 'Cual es la proyeccion para este mes?',
};
setInputValue(actionQueries[action] || '');
inputRef.current?.focus();
};
// Limpiar chat
const handleClearChat = () => {
setMessages([]);
};
// Copiar mensaje
const handleCopyMessage = (content: string) => {
navigator.clipboard.writeText(content);
};
// Tecla Enter para enviar
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const hasMessages = messages.length > 0;
return (
<div className={cn('flex h-full bg-slate-50 dark:bg-slate-900', className)}>
{/* Sidebar */}
{sidebarOpen && (
<div className="hidden lg:flex flex-col w-80 border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
{/* Sidebar Header */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
<Bot className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="font-semibold text-slate-900 dark:text-white">CFO Digital</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">Asistente financiero IA</p>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-3">
Acciones Rapidas
</h3>
<QuickActions onAction={handleQuickAction} />
</div>
{/* Suggested Questions */}
<div className="flex-1 overflow-y-auto p-4">
<SuggestedQuestions
questions={suggestedQuestions.slice(0, 6)}
onSelect={handleSuggestedQuestion}
title="Sugerencias"
/>
</div>
{/* Sidebar Footer */}
<div className="p-4 border-t border-slate-200 dark:border-slate-700">
<button
onClick={handleClearChat}
disabled={!hasMessages}
className={cn(
'w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg',
'text-sm font-medium transition-colors',
'text-slate-600 dark:text-slate-400',
hasMessages
? 'hover:bg-slate-100 dark:hover:bg-slate-700'
: 'opacity-50 cursor-not-allowed'
)}
>
<Trash2 className="w-4 h-4" />
Limpiar conversacion
</button>
</div>
</div>
)}
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Chat Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="lg:hidden p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Menu className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</button>
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-violet-500" />
<span className="font-medium text-slate-900 dark:text-white">
Asistente CFO Digital
</span>
</div>
</div>
<button className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400">
<Settings className="w-5 h-5" />
</button>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Empty State */}
{!hasMessages && (
<div className="h-full flex flex-col items-center justify-center text-center p-6">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-6">
<Bot className="w-10 h-10 text-white" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
Hola! Soy tu CFO Digital
</h2>
<p className="text-slate-500 dark:text-slate-400 max-w-md mb-8">
Estoy aqui para ayudarte a entender mejor la salud financiera de tu empresa.
Preguntame sobre metricas, proyecciones, o cualquier duda financiera.
</p>
<div className="w-full max-w-lg">
<SuggestedQuestions
questions={suggestedQuestions.slice(0, 4)}
onSelect={handleSuggestedQuestion}
title="Comienza con una pregunta:"
/>
</div>
</div>
)}
{/* Messages */}
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
onCopy={handleCopyMessage}
/>
))}
<div ref={messagesEndRef} />
</div>
{/* Suggested questions (when chat started) */}
{hasMessages && !isStreaming && (
<div className="px-4 pb-2">
<SuggestedQuestions
questions={suggestedQuestions.slice(0, 3)}
onSelect={handleSuggestedQuestion}
compact
/>
</div>
)}
{/* Input Area */}
<div className="p-4 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800">
<div className="flex items-end gap-3">
{/* Attach Button */}
<button className="flex-shrink-0 p-2.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors">
<Paperclip className="w-5 h-5" />
</button>
{/* Input */}
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Escribe tu pregunta..."
rows={1}
className={cn(
'w-full resize-none rounded-xl border border-slate-300 dark:border-slate-600',
'bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white',
'px-4 py-3 pr-12',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'placeholder:text-slate-400 dark:placeholder:text-slate-500',
'max-h-[200px]'
)}
/>
</div>
{/* Voice Button */}
<button className="flex-shrink-0 p-2.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors">
<Mic className="w-5 h-5" />
</button>
{/* Send/Stop Button */}
{isStreaming ? (
<Button
variant="danger"
size="md"
onClick={() => setIsStreaming(false)}
leftIcon={<StopCircle className="w-5 h-5" />}
>
Detener
</Button>
) : (
<Button
variant="primary"
size="md"
onClick={handleSendMessage}
disabled={!inputValue.trim() || isLoading}
leftIcon={<Send className="w-5 h-5" />}
>
Enviar
</Button>
)}
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 text-center mt-2">
El CFO Digital usa IA para proporcionar insights. Siempre verifica la informacion importante.
</p>
</div>
</div>
</div>
);
};
export default ChatInterface;

View File

@@ -0,0 +1,281 @@
'use client';
import React from 'react';
import { cn, formatDate } from '@/lib/utils';
import { Bot, User, Copy, Check, RefreshCw, ThumbsUp, ThumbsDown } from 'lucide-react';
/**
* Tipo de mensaje
*/
export type MessageRole = 'user' | 'assistant' | 'system';
/**
* Contenido inline del mensaje (metricas, graficas, etc.)
*/
export interface MessageInlineContent {
type: 'metric' | 'chart' | 'table' | 'alert';
data: Record<string, unknown>;
}
/**
* Interface del mensaje
*/
export interface Message {
id: string;
role: MessageRole;
content: string;
timestamp: Date;
inlineContent?: MessageInlineContent[];
isStreaming?: boolean;
isError?: boolean;
}
/**
* Props del componente ChatMessage
*/
interface ChatMessageProps {
message: Message;
onCopy?: (content: string) => void;
onRetry?: (message: Message) => void;
onFeedback?: (message: Message, isPositive: boolean) => void;
className?: string;
}
/**
* Renderizar contenido inline
*/
const InlineContentRenderer: React.FC<{ content: MessageInlineContent }> = ({ content }) => {
switch (content.type) {
case 'metric':
return (
<div className="my-3 p-3 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
<div className="flex items-center justify-between">
<span className="text-sm text-primary-700 dark:text-primary-300">
{content.data.label as string}
</span>
<span className="text-lg font-bold text-primary-900 dark:text-primary-100">
{content.data.value as string}
</span>
</div>
{content.data.change && (
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
{content.data.change as string}
</p>
)}
</div>
);
case 'alert':
const alertType = (content.data.type as string) || 'info';
const alertStyles = {
info: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-300',
success: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800 text-success-700 dark:text-success-300',
warning: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 text-warning-700 dark:text-warning-300',
error: 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800 text-error-700 dark:text-error-300',
};
return (
<div className={cn('my-3 p-3 rounded-lg border', alertStyles[alertType as keyof typeof alertStyles] || alertStyles.info)}>
<p className="font-medium text-sm">{content.data.title as string}</p>
{content.data.message && (
<p className="text-sm mt-1 opacity-80">{content.data.message as string}</p>
)}
</div>
);
case 'table':
const headers = (content.data.headers || []) as string[];
const rows = (content.data.rows || []) as string[][];
return (
<div className="my-3 overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
{headers.map((header, i) => (
<th
key={i}
className="px-4 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-slate-900 divide-y divide-slate-200 dark:divide-slate-700">
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
className="px-4 py-2 whitespace-nowrap text-sm text-slate-900 dark:text-slate-100"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
default:
return null;
}
};
/**
* Componente ChatMessage
*
* Muestra un mensaje individual del chat con soporte para
* contenido enriquecido, streaming y acciones.
*/
export const ChatMessage: React.FC<ChatMessageProps> = ({
message,
onCopy,
onRetry,
onFeedback,
className,
}) => {
const [copied, setCopied] = React.useState(false);
const isUser = message.role === 'user';
const isAssistant = message.role === 'assistant';
const handleCopy = () => {
if (onCopy) {
onCopy(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
// Procesar contenido para markdown basico
const processContent = (content: string) => {
// Convertir **texto** a negrita
let processed = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Convertir *texto* a italica
processed = processed.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Convertir `codigo` a codigo inline
processed = processed.replace(/`(.*?)`/g, '<code class="px-1 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-sm">$1</code>');
// Convertir lineas nuevas
processed = processed.replace(/\n/g, '<br />');
return processed;
};
return (
<div
className={cn(
'flex gap-3',
isUser ? 'flex-row-reverse' : 'flex-row',
className
)}
>
{/* Avatar */}
<div
className={cn(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isUser
? 'bg-primary-500 text-white'
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'
)}
>
{isUser ? (
<User className="w-4 h-4" />
) : (
<Bot className="w-4 h-4" />
)}
</div>
{/* Message Content */}
<div className={cn('flex-1 max-w-[85%]', isUser && 'flex flex-col items-end')}>
{/* Header */}
<div className={cn('flex items-center gap-2 mb-1', isUser && 'flex-row-reverse')}>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{isUser ? 'Tu' : 'CFO Digital'}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
{formatDate(message.timestamp, { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{/* Bubble */}
<div
className={cn(
'relative px-4 py-3 rounded-2xl',
isUser
? 'bg-primary-500 text-white rounded-tr-none'
: 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-white rounded-tl-none',
message.isError && 'bg-error-100 dark:bg-error-900/30 border border-error-300 dark:border-error-700'
)}
>
{/* Content */}
<div
className={cn(
'text-sm leading-relaxed',
message.isStreaming && 'animate-pulse'
)}
dangerouslySetInnerHTML={{ __html: processContent(message.content) }}
/>
{/* Streaming indicator */}
{message.isStreaming && (
<span className="inline-block w-1.5 h-4 ml-1 bg-current animate-pulse rounded" />
)}
{/* Inline Content */}
{message.inlineContent?.map((content, index) => (
<InlineContentRenderer key={index} content={content} />
))}
</div>
{/* Actions for assistant messages */}
{isAssistant && !message.isStreaming && (
<div className="flex items-center gap-1 mt-2">
<button
onClick={handleCopy}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors"
title="Copiar respuesta"
>
{copied ? <Check className="w-4 h-4 text-success-500" /> : <Copy className="w-4 h-4" />}
</button>
{onRetry && (
<button
onClick={() => onRetry(message)}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 dark:hover:text-slate-300 dark:hover:bg-slate-700 transition-colors"
title="Regenerar respuesta"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
{onFeedback && (
<>
<div className="w-px h-4 bg-slate-200 dark:bg-slate-600 mx-1" />
<button
onClick={() => onFeedback(message, true)}
className="p-1.5 rounded-lg text-slate-400 hover:text-success-600 hover:bg-success-50 dark:hover:text-success-400 dark:hover:bg-success-900/30 transition-colors"
title="Respuesta util"
>
<ThumbsUp className="w-4 h-4" />
</button>
<button
onClick={() => onFeedback(message, false)}
className="p-1.5 rounded-lg text-slate-400 hover:text-error-600 hover:bg-error-50 dark:hover:text-error-400 dark:hover:bg-error-900/30 transition-colors"
title="Respuesta no util"
>
<ThumbsDown className="w-4 h-4" />
</button>
</>
)}
</div>
)}
</div>
</div>
);
};
export default ChatMessage;

View File

@@ -0,0 +1,265 @@
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
import {
TrendingUp,
DollarSign,
PieChart,
Users,
AlertTriangle,
Target,
Calendar,
BarChart3,
} from 'lucide-react';
/**
* Categoria de pregunta sugerida
*/
type QuestionCategory = 'financiero' | 'metricas' | 'clientes' | 'predicciones' | 'alertas';
/**
* Pregunta sugerida
*/
export interface SuggestedQuestion {
id: string;
text: string;
category: QuestionCategory;
icon?: React.ReactNode;
}
/**
* Props del componente SuggestedQuestions
*/
interface SuggestedQuestionsProps {
questions: SuggestedQuestion[];
onSelect: (question: SuggestedQuestion) => void;
title?: string;
className?: string;
compact?: boolean;
}
/**
* Icono por defecto segun categoria
*/
const defaultIcons: Record<QuestionCategory, React.ReactNode> = {
financiero: <DollarSign className="w-4 h-4" />,
metricas: <BarChart3 className="w-4 h-4" />,
clientes: <Users className="w-4 h-4" />,
predicciones: <TrendingUp className="w-4 h-4" />,
alertas: <AlertTriangle className="w-4 h-4" />,
};
/**
* Estilos por categoria
*/
const categoryStyles: Record<QuestionCategory, { bg: string; text: string; border: string; hover: string }> = {
financiero: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
text: 'text-emerald-700 dark:text-emerald-400',
border: 'border-emerald-200 dark:border-emerald-800',
hover: 'hover:bg-emerald-100 dark:hover:bg-emerald-900/40',
},
metricas: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
text: 'text-blue-700 dark:text-blue-400',
border: 'border-blue-200 dark:border-blue-800',
hover: 'hover:bg-blue-100 dark:hover:bg-blue-900/40',
},
clientes: {
bg: 'bg-purple-50 dark:bg-purple-900/20',
text: 'text-purple-700 dark:text-purple-400',
border: 'border-purple-200 dark:border-purple-800',
hover: 'hover:bg-purple-100 dark:hover:bg-purple-900/40',
},
predicciones: {
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-700 dark:text-amber-400',
border: 'border-amber-200 dark:border-amber-800',
hover: 'hover:bg-amber-100 dark:hover:bg-amber-900/40',
},
alertas: {
bg: 'bg-red-50 dark:bg-red-900/20',
text: 'text-red-700 dark:text-red-400',
border: 'border-red-200 dark:border-red-800',
hover: 'hover:bg-red-100 dark:hover:bg-red-900/40',
},
};
/**
* Preguntas sugeridas por defecto
*/
export const defaultSuggestedQuestions: SuggestedQuestion[] = [
{
id: '1',
text: 'Como esta mi flujo de caja este mes?',
category: 'financiero',
},
{
id: '2',
text: 'Cuales son mis metricas SaaS clave?',
category: 'metricas',
},
{
id: '3',
text: 'Que clientes tienen facturas vencidas?',
category: 'clientes',
},
{
id: '4',
text: 'Proyecta mis ingresos para el proximo trimestre',
category: 'predicciones',
},
{
id: '5',
text: 'Hay alguna alerta financiera que deba revisar?',
category: 'alertas',
},
{
id: '6',
text: 'Cual es mi margen bruto actual?',
category: 'financiero',
},
{
id: '7',
text: 'Como ha evolucionado mi MRR?',
category: 'metricas',
},
{
id: '8',
text: 'Cual es mi tasa de churn actual?',
category: 'clientes',
},
];
/**
* Componente SuggestedQuestions
*
* Muestra una lista de preguntas sugeridas que el usuario puede
* seleccionar para iniciar una conversacion con el CFO Digital.
*/
export const SuggestedQuestions: React.FC<SuggestedQuestionsProps> = ({
questions,
onSelect,
title = 'Preguntas sugeridas',
className,
compact = false,
}) => {
if (compact) {
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{questions.map((question) => {
const styles = categoryStyles[question.category];
const icon = question.icon || defaultIcons[question.category];
return (
<button
key={question.id}
onClick={() => onSelect(question)}
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-full',
'text-sm font-medium border transition-all',
styles.bg,
styles.text,
styles.border,
styles.hover
)}
>
{icon}
<span className="max-w-[200px] truncate">{question.text}</span>
</button>
);
})}
</div>
);
}
return (
<div className={cn('space-y-3', className)}>
{title && (
<h3 className="text-sm font-medium text-slate-500 dark:text-slate-400">
{title}
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{questions.map((question) => {
const styles = categoryStyles[question.category];
const icon = question.icon || defaultIcons[question.category];
return (
<button
key={question.id}
onClick={() => onSelect(question)}
className={cn(
'flex items-start gap-3 p-3 rounded-lg text-left',
'border transition-all group',
styles.bg,
styles.border,
styles.hover
)}
>
<span
className={cn(
'flex-shrink-0 p-1.5 rounded-md',
styles.text,
'bg-white/50 dark:bg-slate-900/30'
)}
>
{icon}
</span>
<span
className={cn(
'text-sm font-medium leading-snug',
styles.text,
'group-hover:underline'
)}
>
{question.text}
</span>
</button>
);
})}
</div>
</div>
);
};
/**
* Componente QuickActions para accesos rapidos
*/
interface QuickActionsProps {
onAction: (action: string) => void;
className?: string;
}
export const QuickActions: React.FC<QuickActionsProps> = ({ onAction, className }) => {
const actions = [
{ id: 'resumen_diario', label: 'Resumen del dia', icon: <Calendar className="w-4 h-4" /> },
{ id: 'metricas_clave', label: 'Metricas clave', icon: <BarChart3 className="w-4 h-4" /> },
{ id: 'alertas_activas', label: 'Alertas activas', icon: <AlertTriangle className="w-4 h-4" /> },
{ id: 'proyeccion_mes', label: 'Proyeccion del mes', icon: <Target className="w-4 h-4" /> },
];
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{actions.map((action) => (
<button
key={action.id}
onClick={() => onAction(action.id)}
className={cn(
'inline-flex items-center gap-2 px-3 py-2 rounded-lg',
'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors',
'text-sm font-medium'
)}
>
{action.icon}
{action.label}
</button>
))}
</div>
);
};
export default SuggestedQuestions;

View File

@@ -0,0 +1,10 @@
export { ChatMessage } from './ChatMessage';
export type { Message, MessageRole, MessageInlineContent } from './ChatMessage';
export { SuggestedQuestions, QuickActions, defaultSuggestedQuestions } from './SuggestedQuestions';
export type { SuggestedQuestion } from './SuggestedQuestions';
export { AIInsightCard, InsightsList } from './AIInsightCard';
export type { AIInsight, InsightType, InsightPriority } from './AIInsightCard';
export { ChatInterface } from './ChatInterface';

View File

@@ -18,6 +18,8 @@ import {
Bot,
Shield,
Bell,
Plug,
FileText,
} from 'lucide-react';
/**
@@ -93,6 +95,21 @@ const navigation: NavGroup[] = [
},
],
},
{
label: 'Datos',
items: [
{
label: 'Integraciones',
href: '/integraciones',
icon: <Plug className="h-5 w-5" />,
},
{
label: 'Reportes',
href: '/reportes',
icon: <FileText className="h-5 w-5" />,
},
],
},
{
label: 'Sistema',
items: [

View File

@@ -0,0 +1,645 @@
'use client';
import React, { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import {
FileText,
Calendar,
CheckCircle,
ArrowRight,
ArrowLeft,
Loader2,
Check,
CalendarRange,
BarChart3,
PieChart,
TrendingUp,
Users,
Receipt,
DollarSign,
FileBarChart,
} from 'lucide-react';
import type { ReportType } from './ReportCard';
/**
* Seccion de reporte disponible
*/
interface ReportSectionOption {
id: string;
name: string;
description: string;
icon: React.ReactNode;
required?: boolean;
}
/**
* Props del componente GenerateReportWizard
*/
interface GenerateReportWizardProps {
onComplete: (config: ReportConfig) => Promise<void>;
onCancel: () => void;
className?: string;
}
/**
* Configuracion del reporte a generar
*/
export interface ReportConfig {
type: ReportType;
period: {
start: string;
end: string;
};
sections: string[];
title: string;
description?: string;
}
/**
* Opciones de tipo de reporte
*/
const reportTypes: Array<{
type: ReportType;
name: string;
description: string;
icon: React.ReactNode;
}> = [
{
type: 'mensual',
name: 'Reporte Mensual',
description: 'Resumen completo del mes seleccionado',
icon: <Calendar className="w-6 h-6" />,
},
{
type: 'trimestral',
name: 'Reporte Trimestral',
description: 'Analisis de 3 meses consecutivos',
icon: <CalendarRange className="w-6 h-6" />,
},
{
type: 'anual',
name: 'Reporte Anual',
description: 'Vision general del ano fiscal completo',
icon: <FileBarChart className="w-6 h-6" />,
},
{
type: 'custom',
name: 'Reporte Personalizado',
description: 'Define tu propio rango de fechas',
icon: <FileText className="w-6 h-6" />,
},
];
/**
* Secciones disponibles para incluir
*/
const availableSections: ReportSectionOption[] = [
{
id: 'resumen_ejecutivo',
name: 'Resumen Ejecutivo',
description: 'Vision general de metricas clave',
icon: <FileText className="w-5 h-5" />,
required: true,
},
{
id: 'ingresos',
name: 'Analisis de Ingresos',
description: 'Desglose completo de ingresos por categoria',
icon: <TrendingUp className="w-5 h-5" />,
},
{
id: 'egresos',
name: 'Analisis de Egresos',
description: 'Control de gastos y costos operativos',
icon: <Receipt className="w-5 h-5" />,
},
{
id: 'flujo_caja',
name: 'Flujo de Caja',
description: 'Movimientos de efectivo y proyecciones',
icon: <DollarSign className="w-5 h-5" />,
},
{
id: 'cuentas_cobrar',
name: 'Cuentas por Cobrar',
description: 'Estado de facturas pendientes de cobro',
icon: <Users className="w-5 h-5" />,
},
{
id: 'cuentas_pagar',
name: 'Cuentas por Pagar',
description: 'Obligaciones pendientes con proveedores',
icon: <Receipt className="w-5 h-5" />,
},
{
id: 'metricas_saas',
name: 'Metricas SaaS',
description: 'MRR, ARR, Churn, LTV/CAC y mas',
icon: <BarChart3 className="w-5 h-5" />,
},
{
id: 'graficas',
name: 'Graficas y Visualizaciones',
description: 'Representaciones visuales de los datos',
icon: <PieChart className="w-5 h-5" />,
},
];
/**
* Componente GenerateReportWizard
*
* Wizard de 3 pasos para configurar y generar un reporte.
*/
export const GenerateReportWizard: React.FC<GenerateReportWizardProps> = ({
onComplete,
onCancel,
className,
}) => {
const [step, setStep] = useState(1);
const [isGenerating, setIsGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState(0);
// Estado del formulario
const [selectedType, setSelectedType] = useState<ReportType | null>(null);
const [period, setPeriod] = useState({
start: '',
end: '',
});
const [selectedSections, setSelectedSections] = useState<string[]>(['resumen_ejecutivo']);
const [title, setTitle] = useState('');
// Calcular fechas basadas en el tipo
const calculatePeriod = useCallback((type: ReportType) => {
const now = new Date();
let start: Date;
let end: Date = new Date(now.getFullYear(), now.getMonth() + 1, 0);
switch (type) {
case 'mensual':
start = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'trimestral':
const quarter = Math.floor(now.getMonth() / 3);
start = new Date(now.getFullYear(), quarter * 3, 1);
end = new Date(now.getFullYear(), quarter * 3 + 3, 0);
break;
case 'anual':
start = new Date(now.getFullYear(), 0, 1);
end = new Date(now.getFullYear(), 11, 31);
break;
default:
start = new Date(now.getFullYear(), now.getMonth(), 1);
}
setPeriod({
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0],
});
}, []);
// Manejar seleccion de tipo
const handleTypeSelect = (type: ReportType) => {
setSelectedType(type);
if (type !== 'custom') {
calculatePeriod(type);
}
};
// Manejar toggle de seccion
const toggleSection = (sectionId: string) => {
const section = availableSections.find(s => s.id === sectionId);
if (section?.required) return;
setSelectedSections(prev =>
prev.includes(sectionId)
? prev.filter(id => id !== sectionId)
: [...prev, sectionId]
);
};
// Generar titulo automatico
const generateTitle = useCallback(() => {
if (!selectedType || !period.start) return '';
const startDate = new Date(period.start);
const monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
switch (selectedType) {
case 'mensual':
return `Reporte ${monthNames[startDate.getMonth()]} ${startDate.getFullYear()}`;
case 'trimestral':
const quarter = Math.floor(startDate.getMonth() / 3) + 1;
return `Reporte Q${quarter} ${startDate.getFullYear()}`;
case 'anual':
return `Reporte Anual ${startDate.getFullYear()}`;
default:
return `Reporte Personalizado`;
}
}, [selectedType, period.start]);
// Simular generacion del reporte
const handleGenerate = async () => {
if (!selectedType || !period.start || !period.end) return;
setIsGenerating(true);
setGenerationProgress(0);
const config: ReportConfig = {
type: selectedType,
period,
sections: selectedSections,
title: title || generateTitle(),
};
// Simular progreso
const progressInterval = setInterval(() => {
setGenerationProgress(prev => {
if (prev >= 95) {
clearInterval(progressInterval);
return prev;
}
return prev + Math.random() * 10;
});
}, 300);
try {
await onComplete(config);
setGenerationProgress(100);
} catch (error) {
console.error('Error generando reporte:', error);
} finally {
clearInterval(progressInterval);
setIsGenerating(false);
}
};
// Validacion por paso
const canProceed = () => {
switch (step) {
case 1:
return selectedType !== null;
case 2:
return period.start && period.end && selectedSections.length > 0;
case 3:
return true;
default:
return false;
}
};
return (
<div className={cn('bg-white dark:bg-slate-800 rounded-xl', className)}>
{/* Progress Steps */}
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
{[1, 2, 3].map((s) => (
<React.Fragment key={s}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors',
step > s
? 'bg-success-500 text-white'
: step === s
? 'bg-primary-500 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 dark:text-slate-500'
)}
>
{step > s ? <Check className="w-4 h-4" /> : s}
</div>
<span
className={cn(
'text-sm font-medium hidden sm:inline',
step >= s
? 'text-slate-900 dark:text-white'
: 'text-slate-400 dark:text-slate-500'
)}
>
{s === 1 ? 'Tipo' : s === 2 ? 'Configurar' : 'Confirmar'}
</span>
</div>
{s < 3 && (
<div
className={cn(
'flex-1 h-0.5 mx-4 transition-colors',
step > s
? 'bg-success-500'
: 'bg-slate-200 dark:bg-slate-700'
)}
/>
)}
</React.Fragment>
))}
</div>
</div>
{/* Step Content */}
<div className="p-6">
{/* Step 1: Seleccionar Tipo */}
{step === 1 && (
<div className="space-y-4">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Selecciona el tipo de reporte
</h2>
<p className="text-slate-500 dark:text-slate-400 mt-1">
Elige el formato que mejor se ajuste a tus necesidades
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
{reportTypes.map((rt) => (
<button
key={rt.type}
onClick={() => handleTypeSelect(rt.type)}
className={cn(
'p-4 rounded-xl border-2 text-left transition-all',
selectedType === rt.type
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-600'
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'p-2.5 rounded-lg',
selectedType === rt.type
? 'bg-primary-500 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400'
)}
>
{rt.icon}
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-white">
{rt.name}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{rt.description}
</p>
</div>
{selectedType === rt.type && (
<CheckCircle className="w-5 h-5 text-primary-500 ml-auto flex-shrink-0" />
)}
</div>
</button>
))}
</div>
</div>
)}
{/* Step 2: Configurar Periodo y Secciones */}
{step === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Configura el reporte
</h2>
<p className="text-slate-500 dark:text-slate-400 mt-1">
Define el periodo y las secciones a incluir
</p>
</div>
{/* Periodo */}
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 space-y-4">
<h3 className="font-medium text-slate-900 dark:text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-primary-500" />
Periodo del reporte
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Fecha inicio
</label>
<input
type="date"
value={period.start}
onChange={(e) => setPeriod({ ...period, start: e.target.value })}
disabled={selectedType !== 'custom'}
className={cn(
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Fecha fin
</label>
<input
type="date"
value={period.end}
onChange={(e) => setPeriod({ ...period, end: e.target.value })}
disabled={selectedType !== 'custom'}
className={cn(
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
/>
</div>
</div>
</div>
{/* Secciones */}
<div>
<h3 className="font-medium text-slate-900 dark:text-white mb-3">
Secciones a incluir
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{availableSections.map((section) => (
<button
key={section.id}
onClick={() => toggleSection(section.id)}
disabled={section.required}
className={cn(
'p-3 rounded-lg border text-left transition-all',
selectedSections.includes(section.id)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-600',
section.required && 'opacity-80'
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'p-1.5 rounded-md',
selectedSections.includes(section.id)
? 'bg-primary-500 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400'
)}
>
{section.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-slate-900 dark:text-white text-sm">
{section.name}
</h4>
{section.required && (
<span className="text-xs px-1.5 py-0.5 rounded bg-slate-200 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
Requerido
</span>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{section.description}
</p>
</div>
{selectedSections.includes(section.id) && (
<CheckCircle className="w-4 h-4 text-primary-500 flex-shrink-0" />
)}
</div>
</button>
))}
</div>
</div>
</div>
)}
{/* Step 3: Confirmar y Generar */}
{step === 3 && (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
Confirmar y generar
</h2>
<p className="text-slate-500 dark:text-slate-400 mt-1">
Revisa la configuracion antes de generar el reporte
</p>
</div>
{/* Titulo del reporte */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Titulo del reporte
</label>
<input
type="text"
value={title || generateTitle()}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titulo del reporte..."
className={cn(
'w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600',
'bg-white dark:bg-slate-800 text-slate-900 dark:text-white',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent'
)}
/>
</div>
{/* Resumen de configuracion */}
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-900/50 space-y-4">
<h3 className="font-medium text-slate-900 dark:text-white">
Resumen de configuracion
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-700">
<span className="text-sm text-slate-500 dark:text-slate-400">Tipo de reporte</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{reportTypes.find(t => t.type === selectedType)?.name}
</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-slate-200 dark:border-slate-700">
<span className="text-sm text-slate-500 dark:text-slate-400">Periodo</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{period.start} a {period.end}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-slate-500 dark:text-slate-400">Secciones</span>
<span className="text-sm font-medium text-slate-900 dark:text-white">
{selectedSections.length} seleccionadas
</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{selectedSections.map((sectionId) => {
const section = availableSections.find(s => s.id === sectionId);
return (
<span
key={sectionId}
className="text-xs px-2 py-1 rounded-md bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400"
>
{section?.name}
</span>
);
})}
</div>
</div>
{/* Progress durante generacion */}
{isGenerating && (
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
<div className="flex items-center gap-3 mb-3">
<Loader2 className="w-5 h-5 text-primary-600 dark:text-primary-400 animate-spin" />
<span className="font-medium text-primary-700 dark:text-primary-300">
Generando reporte...
</span>
</div>
<div className="h-2 bg-primary-100 dark:bg-primary-900/50 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300"
style={{ width: `${generationProgress}%` }}
/>
</div>
<p className="text-sm text-primary-600 dark:text-primary-400 mt-2">
{generationProgress < 30 && 'Recopilando datos...'}
{generationProgress >= 30 && generationProgress < 60 && 'Procesando metricas...'}
{generationProgress >= 60 && generationProgress < 90 && 'Generando graficas...'}
{generationProgress >= 90 && 'Finalizando reporte...'}
</p>
</div>
)}
</div>
)}
</div>
{/* Footer Actions */}
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex items-center justify-between">
<div>
{step > 1 && !isGenerating && (
<Button
variant="ghost"
onClick={() => setStep(step - 1)}
leftIcon={<ArrowLeft className="w-4 h-4" />}
>
Anterior
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={onCancel} disabled={isGenerating}>
Cancelar
</Button>
{step < 3 ? (
<Button
onClick={() => setStep(step + 1)}
disabled={!canProceed()}
rightIcon={<ArrowRight className="w-4 h-4" />}
>
Siguiente
</Button>
) : (
<Button
onClick={handleGenerate}
disabled={isGenerating}
isLoading={isGenerating}
leftIcon={!isGenerating ? <FileText className="w-4 h-4" /> : undefined}
>
{isGenerating ? 'Generando...' : 'Generar Reporte'}
</Button>
)}
</div>
</div>
</div>
);
};
export default GenerateReportWizard;

View File

@@ -0,0 +1,271 @@
'use client';
import React from 'react';
import { cn, formatDate, formatCurrency } from '@/lib/utils';
import {
FileText,
Download,
Eye,
Clock,
CheckCircle,
AlertCircle,
Calendar,
TrendingUp,
BarChart3,
} from 'lucide-react';
/**
* Tipos de reporte
*/
export type ReportType = 'mensual' | 'trimestral' | 'anual' | 'custom';
export type ReportStatus = 'generando' | 'completado' | 'error' | 'pendiente';
/**
* Interface del reporte
*/
export interface Report {
id: string;
title: string;
type: ReportType;
status: ReportStatus;
period: {
start: string;
end: string;
};
sections: string[];
generatedAt?: string;
fileSize?: string;
summary?: {
ingresos: number;
egresos: number;
utilidad: number;
};
progress?: number;
}
/**
* Props del componente ReportCard
*/
interface ReportCardProps {
report: Report;
onView?: (report: Report) => void;
onDownload?: (report: Report) => void;
className?: string;
}
/**
* Configuracion visual por tipo de reporte
*/
const typeConfig: Record<ReportType, { label: string; color: string; bgColor: string }> = {
mensual: {
label: 'Mensual',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
},
trimestral: {
label: 'Trimestral',
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
},
anual: {
label: 'Anual',
color: 'text-amber-600 dark:text-amber-400',
bgColor: 'bg-amber-100 dark:bg-amber-900/30',
},
custom: {
label: 'Personalizado',
color: 'text-slate-600 dark:text-slate-400',
bgColor: 'bg-slate-100 dark:bg-slate-700',
},
};
/**
* Configuracion visual por estado
*/
const statusConfig: Record<ReportStatus, { label: string; icon: React.ReactNode; color: string; bgColor: string }> = {
completado: {
label: 'Completado',
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-success-600 dark:text-success-400',
bgColor: 'bg-success-100 dark:bg-success-900/30',
},
generando: {
label: 'Generando',
icon: <Clock className="w-4 h-4 animate-spin" />,
color: 'text-primary-600 dark:text-primary-400',
bgColor: 'bg-primary-100 dark:bg-primary-900/30',
},
error: {
label: 'Error',
icon: <AlertCircle className="w-4 h-4" />,
color: 'text-error-600 dark:text-error-400',
bgColor: 'bg-error-100 dark:bg-error-900/30',
},
pendiente: {
label: 'Pendiente',
icon: <Clock className="w-4 h-4" />,
color: 'text-slate-600 dark:text-slate-400',
bgColor: 'bg-slate-100 dark:bg-slate-700',
},
};
/**
* Componente ReportCard
*
* Muestra una tarjeta con informacion resumida de un reporte.
*/
export const ReportCard: React.FC<ReportCardProps> = ({
report,
onView,
onDownload,
className,
}) => {
const typeStyle = typeConfig[report.type];
const statusStyle = statusConfig[report.status];
return (
<div
className={cn(
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700',
'hover:shadow-lg hover:border-primary-300 dark:hover:border-primary-600',
'transition-all duration-200 cursor-pointer group',
className
)}
onClick={() => onView?.(report)}
>
{/* Header */}
<div className="p-5 border-b border-slate-100 dark:border-slate-700">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className={cn('p-2.5 rounded-lg', typeStyle.bgColor)}>
<FileText className={cn('w-5 h-5', typeStyle.color)} />
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{report.title}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className={cn('text-xs font-medium px-2 py-0.5 rounded-full', typeStyle.bgColor, typeStyle.color)}>
{typeStyle.label}
</span>
</div>
</div>
</div>
<span className={cn('flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full', statusStyle.bgColor, statusStyle.color)}>
{statusStyle.icon}
{statusStyle.label}
</span>
</div>
</div>
{/* Body */}
<div className="p-5 space-y-4">
{/* Periodo */}
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<Calendar className="w-4 h-4" />
<span>
{formatDate(report.period.start)} - {formatDate(report.period.end)}
</span>
</div>
{/* Progress bar si esta generando */}
{report.status === 'generando' && report.progress !== undefined && (
<div>
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-slate-500 dark:text-slate-400">Progreso</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{report.progress}%</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-500"
style={{ width: `${report.progress}%` }}
/>
</div>
</div>
)}
{/* Resumen financiero */}
{report.status === 'completado' && report.summary && (
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2.5 rounded-lg bg-success-50 dark:bg-success-900/20">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Ingresos</p>
<p className="text-sm font-semibold text-success-600 dark:text-success-400">
{formatCurrency(report.summary.ingresos)}
</p>
</div>
<div className="text-center p-2.5 rounded-lg bg-error-50 dark:bg-error-900/20">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Egresos</p>
<p className="text-sm font-semibold text-error-600 dark:text-error-400">
{formatCurrency(report.summary.egresos)}
</p>
</div>
<div className="text-center p-2.5 rounded-lg bg-primary-50 dark:bg-primary-900/20">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Utilidad</p>
<p className="text-sm font-semibold text-primary-600 dark:text-primary-400">
{formatCurrency(report.summary.utilidad)}
</p>
</div>
</div>
)}
{/* Secciones */}
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">Secciones incluidas:</p>
<div className="flex flex-wrap gap-1.5">
{report.sections.slice(0, 4).map((section) => (
<span
key={section}
className="text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300"
>
{section}
</span>
))}
{report.sections.length > 4 && (
<span className="text-xs px-2 py-1 rounded-md bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
+{report.sections.length - 4} mas
</span>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-slate-100 dark:border-slate-700 flex items-center justify-between">
<div className="text-xs text-slate-500 dark:text-slate-400">
{report.generatedAt && (
<span>Generado: {formatDate(report.generatedAt)}</span>
)}
{report.fileSize && (
<span className="ml-2">({report.fileSize})</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onView?.(report);
}}
className="p-2 rounded-lg text-slate-500 hover:text-primary-600 hover:bg-primary-50 dark:hover:text-primary-400 dark:hover:bg-primary-900/30 transition-colors"
title="Ver reporte"
>
<Eye className="w-4 h-4" />
</button>
{report.status === 'completado' && (
<button
onClick={(e) => {
e.stopPropagation();
onDownload?.(report);
}}
className="p-2 rounded-lg text-slate-500 hover:text-success-600 hover:bg-success-50 dark:hover:text-success-400 dark:hover:bg-success-900/30 transition-colors"
title="Descargar PDF"
>
<Download className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
);
};
export default ReportCard;

View File

@@ -0,0 +1,334 @@
'use client';
import React from 'react';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { cn, formatCurrency, formatNumber } from '@/lib/utils';
/**
* Tipos de grafica
*/
type ChartType = 'line' | 'area' | 'bar' | 'pie';
/**
* Datos de la grafica
*/
interface ChartDataPoint {
name: string;
[key: string]: string | number;
}
/**
* Configuracion de serie
*/
interface ChartSeries {
dataKey: string;
name: string;
color: string;
type?: 'monotone' | 'linear' | 'step';
}
/**
* Props del componente ReportChart
*/
interface ReportChartProps {
title?: string;
subtitle?: string;
type: ChartType;
data: ChartDataPoint[];
series?: ChartSeries[];
height?: number;
showLegend?: boolean;
showGrid?: boolean;
formatTooltip?: 'currency' | 'number' | 'percentage';
className?: string;
}
/**
* Colores por defecto para las series
*/
const defaultColors = [
'#0c8ce8', // primary
'#10b981', // success
'#f59e0b', // warning
'#ef4444', // error
'#8b5cf6', // purple
'#06b6d4', // cyan
'#ec4899', // pink
'#f97316', // orange
];
/**
* Componente de Tooltip personalizado
*/
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{
name: string;
value: number;
color: string;
dataKey: string;
}>;
label?: string;
format?: 'currency' | 'number' | 'percentage';
}
const CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, format }) => {
if (!active || !payload) return null;
const formatValue = (value: number) => {
switch (format) {
case 'currency':
return formatCurrency(value);
case 'percentage':
return `${value.toFixed(1)}%`;
case 'number':
default:
return formatNumber(value, value % 1 === 0 ? 0 : 2);
}
};
return (
<div className="bg-slate-900 dark:bg-slate-800 px-4 py-3 rounded-lg shadow-lg border border-slate-700">
<p className="text-sm font-medium text-white mb-2">{label}</p>
<div className="space-y-1">
{payload.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-slate-400">{item.name}:</span>
<span className="text-xs font-semibold text-white">
{formatValue(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
/**
* Componente ReportChart
*
* Grafica interactiva para reportes con soporte para multiples tipos.
*/
export const ReportChart: React.FC<ReportChartProps> = ({
title,
subtitle,
type,
data,
series,
height = 300,
showLegend = true,
showGrid = true,
formatTooltip = 'number',
className,
}) => {
// Generar series por defecto si no se proporcionan
const chartSeries: ChartSeries[] = series || (data.length > 0
? Object.keys(data[0])
.filter((key) => key !== 'name' && typeof data[0][key] === 'number')
.map((key, index) => ({
dataKey: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
color: defaultColors[index % defaultColors.length],
}))
: []);
const renderChart = () => {
switch (type) {
case 'line':
return (
<LineChart data={data}>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
{chartSeries.map((s) => (
<Line
key={s.dataKey}
type={s.type || 'monotone'}
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
strokeWidth={2}
dot={{ r: 4, fill: s.color }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
);
case 'area':
return (
<AreaChart data={data}>
<defs>
{chartSeries.map((s) => (
<linearGradient key={`gradient-${s.dataKey}`} id={`gradient-${s.dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.4} />
<stop offset="100%" stopColor={s.color} stopOpacity={0.05} />
</linearGradient>
))}
</defs>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
{chartSeries.map((s) => (
<Area
key={s.dataKey}
type={s.type || 'monotone'}
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
strokeWidth={2}
fill={`url(#gradient-${s.dataKey})`}
/>
))}
</AreaChart>
);
case 'bar':
return (
<BarChart data={data}>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />}
<XAxis dataKey="name" stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
{chartSeries.map((s) => (
<Bar
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
fill={s.color}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
);
case 'pie':
const pieData = data.map((d, index) => ({
...d,
fill: defaultColors[index % defaultColors.length],
}));
const pieDataKey = chartSeries[0]?.dataKey || 'value';
return (
<PieChart>
<Pie
data={pieData}
dataKey={pieDataKey}
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
innerRadius={60}
paddingAngle={2}
label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`}
labelLine={{ stroke: '#6b7280' }}
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip format={formatTooltip} />} />
{showLegend && <Legend />}
</PieChart>
);
default:
return null;
}
};
return (
<div className={cn('bg-white dark:bg-slate-800 rounded-xl p-5', className)}>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</div>
</div>
);
};
/**
* Componente de grafica mini para previews
*/
interface MiniChartProps {
data: number[];
color?: string;
height?: number;
className?: string;
}
export const MiniChart: React.FC<MiniChartProps> = ({
data,
color = '#0c8ce8',
height = 40,
className,
}) => {
const chartData = data.map((value, index) => ({ value, name: index.toString() }));
return (
<div className={cn('w-full', className)} style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="miniGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill="url(#miniGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
};
export default ReportChart;

View File

@@ -0,0 +1,211 @@
'use client';
import React, { useState } from 'react';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronRight } from 'lucide-react';
/**
* Props del componente ReportSection
*/
interface ReportSectionProps {
title: string;
icon?: React.ReactNode;
children: React.ReactNode;
defaultExpanded?: boolean;
className?: string;
headerAction?: React.ReactNode;
badge?: string | number;
}
/**
* Componente ReportSection
*
* Seccion colapsable para mostrar partes del reporte.
*/
export const ReportSection: React.FC<ReportSectionProps> = ({
title,
icon,
children,
defaultExpanded = true,
className,
headerAction,
badge,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div
className={cn(
'bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700',
'overflow-hidden',
className
)}
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'w-full flex items-center justify-between p-4',
'hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors',
'text-left'
)}
>
<div className="flex items-center gap-3">
{/* Chevron */}
<span className="text-slate-400 dark:text-slate-500">
{isExpanded ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</span>
{/* Icon */}
{icon && (
<span className="text-primary-600 dark:text-primary-400">
{icon}
</span>
)}
{/* Title */}
<h3 className="font-semibold text-slate-900 dark:text-white">
{title}
</h3>
{/* Badge */}
{badge !== undefined && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{badge}
</span>
)}
</div>
{/* Header Action */}
{headerAction && (
<div onClick={(e) => e.stopPropagation()}>
{headerAction}
</div>
)}
</button>
{/* Content */}
<div
className={cn(
'overflow-hidden transition-all duration-300 ease-in-out',
isExpanded ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className="p-4 pt-0 border-t border-slate-100 dark:border-slate-700">
{children}
</div>
</div>
</div>
);
};
/**
* Sub-seccion para contenido anidado
*/
interface ReportSubSectionProps {
title: string;
children: React.ReactNode;
className?: string;
}
export const ReportSubSection: React.FC<ReportSubSectionProps> = ({
title,
children,
className,
}) => {
return (
<div className={cn('mt-4', className)}>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
{title}
</h4>
<div className="pl-4 border-l-2 border-slate-200 dark:border-slate-600">
{children}
</div>
</div>
);
};
/**
* Fila de datos para tablas del reporte
*/
interface ReportDataRowProps {
label: string;
value: string | number | React.ReactNode;
subLabel?: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
className?: string;
}
export const ReportDataRow: React.FC<ReportDataRowProps> = ({
label,
value,
subLabel,
trend,
trendValue,
className,
}) => {
const trendColors = {
up: 'text-success-600 dark:text-success-400',
down: 'text-error-600 dark:text-error-400',
neutral: 'text-slate-500 dark:text-slate-400',
};
return (
<div className={cn('flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700 last:border-0', className)}>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">{label}</p>
{subLabel && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{subLabel}</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-white">
{value}
</span>
{trend && trendValue && (
<span className={cn('text-xs font-medium', trendColors[trend])}>
{trendValue}
</span>
)}
</div>
</div>
);
};
/**
* Alerta o nota dentro del reporte
*/
interface ReportAlertProps {
type: 'info' | 'warning' | 'success' | 'error';
title: string;
message: string;
className?: string;
}
export const ReportAlert: React.FC<ReportAlertProps> = ({
type,
title,
message,
className,
}) => {
const styles = {
info: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800 text-primary-800 dark:text-primary-300',
warning: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 text-warning-800 dark:text-warning-300',
success: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800 text-success-800 dark:text-success-300',
error: 'bg-error-50 dark:bg-error-900/20 border-error-200 dark:border-error-800 text-error-800 dark:text-error-300',
};
return (
<div className={cn('p-4 rounded-lg border', styles[type], className)}>
<p className="font-medium text-sm">{title}</p>
<p className="text-sm mt-1 opacity-80">{message}</p>
</div>
);
};
export default ReportSection;

View File

@@ -0,0 +1,9 @@
export { ReportCard } from './ReportCard';
export type { Report, ReportType, ReportStatus } from './ReportCard';
export { ReportSection, ReportSubSection, ReportDataRow, ReportAlert } from './ReportSection';
export { ReportChart, MiniChart } from './ReportChart';
export { GenerateReportWizard } from './GenerateReportWizard';
export type { ReportConfig } from './GenerateReportWizard';