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:
455
apps/web/src/app/(dashboard)/asistente/page.tsx
Normal file
455
apps/web/src/app/(dashboard)/asistente/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
719
apps/web/src/app/(dashboard)/integraciones/page.tsx
Normal file
719
apps/web/src/app/(dashboard)/integraciones/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
704
apps/web/src/app/(dashboard)/reportes/[id]/page.tsx
Normal file
704
apps/web/src/app/(dashboard)/reportes/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx
Normal file
126
apps/web/src/app/(dashboard)/reportes/nuevo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
685
apps/web/src/app/(dashboard)/reportes/page.tsx
Normal file
685
apps/web/src/app/(dashboard)/reportes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
338
apps/web/src/components/ai/AIInsightCard.tsx
Normal file
338
apps/web/src/components/ai/AIInsightCard.tsx
Normal 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;
|
||||
458
apps/web/src/components/ai/ChatInterface.tsx
Normal file
458
apps/web/src/components/ai/ChatInterface.tsx
Normal 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;
|
||||
281
apps/web/src/components/ai/ChatMessage.tsx
Normal file
281
apps/web/src/components/ai/ChatMessage.tsx
Normal 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;
|
||||
265
apps/web/src/components/ai/SuggestedQuestions.tsx
Normal file
265
apps/web/src/components/ai/SuggestedQuestions.tsx
Normal 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;
|
||||
10
apps/web/src/components/ai/index.ts
Normal file
10
apps/web/src/components/ai/index.ts
Normal 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';
|
||||
@@ -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: [
|
||||
|
||||
645
apps/web/src/components/reports/GenerateReportWizard.tsx
Normal file
645
apps/web/src/components/reports/GenerateReportWizard.tsx
Normal 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;
|
||||
271
apps/web/src/components/reports/ReportCard.tsx
Normal file
271
apps/web/src/components/reports/ReportCard.tsx
Normal 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;
|
||||
334
apps/web/src/components/reports/ReportChart.tsx
Normal file
334
apps/web/src/components/reports/ReportChart.tsx
Normal 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;
|
||||
211
apps/web/src/components/reports/ReportSection.tsx
Normal file
211
apps/web/src/components/reports/ReportSection.tsx
Normal 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;
|
||||
9
apps/web/src/components/reports/index.ts
Normal file
9
apps/web/src/components/reports/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user