diff --git a/src/App.tsx b/src/App.tsx index cf144bd..fbca158 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ const SecuritySettings = lazy(() => import('./modules/auth/pages/SecuritySetting const Dashboard = lazy(() => import('./modules/dashboard/pages/Dashboard')); const Trading = lazy(() => import('./modules/trading/pages/Trading')); const TradingAgents = lazy(() => import('./modules/trading/pages/AgentsPage')); +const BotDetail = lazy(() => import('./modules/trading/pages/BotDetailPage')); const MLDashboard = lazy(() => import('./modules/ml/pages/MLDashboard')); const BacktestingDashboard = lazy(() => import('./modules/backtesting/pages/BacktestingDashboard')); const Investment = lazy(() => import('./modules/investment/pages/Investment')); @@ -37,8 +38,11 @@ const InvestmentTransactions = lazy(() => import('./modules/investment/pages/Tra const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports')); const ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail')); const KYCVerification = lazy(() => import('./modules/investment/pages/KYCVerification')); +const InvestmentDeposit = lazy(() => import('./modules/investment/pages/Deposit')); +const InvestmentWithdraw = lazy(() => import('./modules/investment/pages/Withdraw')); const Settings = lazy(() => import('./modules/settings/pages/Settings')); const Assistant = lazy(() => import('./modules/assistant/pages/Assistant')); +const AgentSettingsPage = lazy(() => import('./modules/assistant/pages/AgentSettingsPage')); // Lazy load modules - Portfolio const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard')); @@ -60,6 +64,9 @@ const Pricing = lazy(() => import('./modules/payments/pages/Pricing')); const Billing = lazy(() => import('./modules/payments/pages/Billing')); const CheckoutSuccess = lazy(() => import('./modules/payments/pages/CheckoutSuccess')); const CheckoutCancel = lazy(() => import('./modules/payments/pages/CheckoutCancel')); +const InvoicesPage = lazy(() => import('./modules/payments/pages/InvoicesPage')); +const RefundsPage = lazy(() => import('./modules/payments/pages/RefundsPage')); +const PaymentMethodsPage = lazy(() => import('./modules/payments/pages/PaymentMethodsPage')); // Lazy load modules - Notifications const NotificationsPage = lazy(() => import('./modules/notifications/pages/NotificationsPage')); @@ -74,6 +81,9 @@ const PredictionsPage = lazy(() => import('./modules/admin/pages/PredictionsPage const MarketplaceCatalog = lazy(() => import('./modules/marketplace/pages/MarketplaceCatalog')); const SignalPackDetail = lazy(() => import('./modules/marketplace/pages/SignalPackDetail')); const AdvisoryDetail = lazy(() => import('./modules/marketplace/pages/AdvisoryDetail')); +const CheckoutFlow = lazy(() => import('./modules/marketplace/pages/CheckoutFlow')); +const SellerDashboard = lazy(() => import('./modules/marketplace/pages/SellerDashboard')); +const CreateProductWizard = lazy(() => import('./modules/marketplace/pages/CreateProductWizard')); function App() { return ( @@ -104,6 +114,7 @@ function App() { {/* Trading */} } /> } /> + } /> } /> } /> } /> @@ -115,6 +126,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* Portfolio Manager */} } /> @@ -135,6 +148,9 @@ function App() { {/* Payments */} } /> } /> + } /> + } /> + } /> } /> } /> @@ -149,6 +165,7 @@ function App() { {/* Assistant */} } /> + } /> {/* Admin */} } /> @@ -161,6 +178,10 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> {/* Redirects */} diff --git a/src/components/payments/PaymentMethodsManager.tsx b/src/components/payments/PaymentMethodsManager.tsx new file mode 100644 index 0000000..830631b --- /dev/null +++ b/src/components/payments/PaymentMethodsManager.tsx @@ -0,0 +1,304 @@ +/** + * PaymentMethodsManager Component + * Complete payment methods management with Stripe Elements for adding new cards + * Epic: OQI-005 Pagos y Stripe + */ + +import React, { useState, useCallback } from 'react'; +import { + CreditCard, + Plus, + X, + Loader2, + AlertCircle, + Shield, +} from 'lucide-react'; +import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { StripeCardElementChangeEvent } from '@stripe/stripe-js'; + +import PaymentMethodsList from './PaymentMethodsList'; +import StripeElementsWrapper from './StripeElementsWrapper'; +import { + usePaymentMethods, + useAddPaymentMethod, +} from '../../hooks/usePayments'; + +// Card Element options for dark theme +const CARD_ELEMENT_OPTIONS = { + style: { + base: { + color: '#f1f5f9', + fontFamily: 'Inter, system-ui, sans-serif', + fontSize: '16px', + fontSmoothing: 'antialiased', + '::placeholder': { + color: '#64748b', + }, + }, + invalid: { + color: '#ef4444', + iconColor: '#ef4444', + }, + }, + hidePostalCode: true, +}; + +// ============================================================================ +// Add Payment Method Form (internal, wrapped in Elements) +// ============================================================================ + +interface AddPaymentMethodFormProps { + onSuccess: () => void; + onCancel: () => void; +} + +const AddPaymentMethodForm: React.FC = ({ + onSuccess, + onCancel, +}) => { + const stripe = useStripe(); + const elements = useElements(); + const addPaymentMethod = useAddPaymentMethod(); + + const [isProcessing, setIsProcessing] = useState(false); + const [cardError, setCardError] = useState(null); + const [cardComplete, setCardComplete] = useState(false); + const [setAsDefault, setSetAsDefault] = useState(true); + + const handleCardChange = useCallback((event: StripeCardElementChangeEvent) => { + setCardError(event.error?.message || null); + setCardComplete(event.complete); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const cardElement = elements.getElement(CardElement); + if (!cardElement) { + return; + } + + setIsProcessing(true); + setCardError(null); + + try { + // Create payment method with Stripe + const { error, paymentMethod } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + }); + + if (error) { + setCardError(error.message || 'Failed to create payment method'); + setIsProcessing(false); + return; + } + + if (!paymentMethod) { + setCardError('Failed to create payment method'); + setIsProcessing(false); + return; + } + + // Save to backend + await addPaymentMethod.mutateAsync(paymentMethod.id); + + onSuccess(); + } catch (err) { + setCardError(err instanceof Error ? err.message : 'Failed to add payment method'); + } finally { + setIsProcessing(false); + } + }; + + return ( +
+ {/* Card Element */} +
+ +
+ +
+ {cardError && ( +

+ + {cardError} +

+ )} +
+ + {/* Set as Default Checkbox */} + + + {/* Security Note */} +
+ +

+ Your card information is securely processed by Stripe and never stored on our servers. +

+
+ + {/* Actions */} +
+ + +
+
+ ); +}; + +// ============================================================================ +// Main PaymentMethodsManager Component +// ============================================================================ + +export interface PaymentMethodsManagerProps { + title?: string; + showTitle?: boolean; + compact?: boolean; + onMethodChange?: () => void; +} + +const PaymentMethodsManager: React.FC = ({ + title = 'Payment Methods', + showTitle = true, + compact = false, + onMethodChange, +}) => { + const [showAddForm, setShowAddForm] = useState(false); + const { data: methods, isLoading, error, refetch } = usePaymentMethods(); + + const handleAddSuccess = useCallback(() => { + setShowAddForm(false); + refetch(); + onMethodChange?.(); + }, [refetch, onMethodChange]); + + const handleAddNew = useCallback(() => { + setShowAddForm(true); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

+ {error instanceof Error ? error.message : 'Failed to load payment methods'} +

+ +
+ ); + } + + return ( +
+ {/* Header */} + {showTitle && ( +
+
+
+ +
+
+

{title}

+

+ {methods?.length ?? 0} saved payment method{methods?.length !== 1 ? 's' : ''} +

+
+
+ {!showAddForm && ( + + )} +
+ )} + + {/* Add Payment Method Form */} + {showAddForm && ( +
+
+

+ + Add New Payment Method +

+ +
+ + setShowAddForm(false)} + /> + +
+ )} + + {/* Payment Methods List */} + +
+ ); +}; + +export default PaymentMethodsManager; diff --git a/src/components/payments/RefundList.tsx b/src/components/payments/RefundList.tsx index 01cca8e..7df92fe 100644 --- a/src/components/payments/RefundList.tsx +++ b/src/components/payments/RefundList.tsx @@ -40,8 +40,8 @@ export interface Refund { reasonDetails?: string; status: RefundStatus; refundMethod: 'original' | 'wallet'; - requestedAt: Date; - processedAt?: Date; + requestedAt: string; + processedAt?: string; failureReason?: string; transactionId?: string; } @@ -103,22 +103,24 @@ const formatCurrency = (amount: number, currency: string = 'USD') => { }).format(amount); }; -// Date formatter -const formatDate = (date: Date) => { +// Date formatter (accepts Date or string) +function formatDate(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date; return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', - }).format(date); -}; + }).format(d); +} -// Time formatter -const formatTime = (date: Date) => { +// Time formatter (accepts Date or string) +function formatTime(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date; return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', - }).format(date); -}; + }).format(d); +} // Status badge component const StatusBadge: React.FC<{ status: RefundStatus }> = ({ status }) => { diff --git a/src/components/payments/index.ts b/src/components/payments/index.ts index 20951e6..061bf17 100644 --- a/src/components/payments/index.ts +++ b/src/components/payments/index.ts @@ -31,6 +31,8 @@ export type { InvoiceLineItem, InvoiceDiscount, InvoiceTax, InvoicePreviewData } // Payment Methods Management export { default as PaymentMethodsList } from './PaymentMethodsList'; export type { PaymentMethod } from './PaymentMethodsList'; +export { default as PaymentMethodsManager } from './PaymentMethodsManager'; +export type { PaymentMethodsManagerProps } from './PaymentMethodsManager'; // Subscription Management export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1cbc94f..8a03650 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -33,3 +33,22 @@ export type { // Feature Flags export { useFeatureFlags } from './useFeatureFlags'; + +// Payment Hooks (OQI-005) +export { + useInvoices, + useInvoice, + useDownloadInvoice, + usePaymentMethods, + useAddPaymentMethod, + useRemovePaymentMethod, + useSetDefaultPaymentMethod, + useRefunds, + useRefund, + useRefundEligibility, + useRequestRefund, + useCancelRefund, + useBillingData, + paymentKeys, +} from './usePayments'; +export type { UseInvoicesOptions, UseRefundsOptions } from './usePayments'; diff --git a/src/hooks/usePayments.ts b/src/hooks/usePayments.ts new file mode 100644 index 0000000..c4a9df6 --- /dev/null +++ b/src/hooks/usePayments.ts @@ -0,0 +1,219 @@ +/** + * Payment Hooks + * TanStack Query hooks for payment operations + * Epic: OQI-005 Pagos y Stripe + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { paymentService } from '../services/payment.service'; +import type { RefundRequest } from '../types/payment.types'; + +// Query Keys +export const paymentKeys = { + all: ['payments'] as const, + invoices: () => [...paymentKeys.all, 'invoices'] as const, + invoicesList: (limit: number, offset: number) => [...paymentKeys.invoices(), { limit, offset }] as const, + invoice: (id: string) => [...paymentKeys.invoices(), id] as const, + paymentMethods: () => [...paymentKeys.all, 'payment-methods'] as const, + refunds: () => [...paymentKeys.all, 'refunds'] as const, + refundsList: (limit: number, offset: number, status?: string) => + [...paymentKeys.refunds(), { limit, offset, status }] as const, + refund: (id: string) => [...paymentKeys.refunds(), id] as const, + refundEligibility: (subscriptionId: string) => + [...paymentKeys.refunds(), 'eligibility', subscriptionId] as const, +}; + +// ============================================================================ +// Invoice Hooks +// ============================================================================ + +export interface UseInvoicesOptions { + limit?: number; + offset?: number; + enabled?: boolean; +} + +export function useInvoices(options: UseInvoicesOptions = {}) { + const { limit = 20, offset = 0, enabled = true } = options; + + return useQuery({ + queryKey: paymentKeys.invoicesList(limit, offset), + queryFn: () => paymentService.getInvoices(limit, offset), + enabled, + staleTime: 30000, // 30 seconds + }); +} + +export function useInvoice(invoiceId: string | undefined) { + return useQuery({ + queryKey: paymentKeys.invoice(invoiceId ?? ''), + queryFn: () => paymentService.getInvoiceById(invoiceId!), + enabled: !!invoiceId, + staleTime: 60000, // 1 minute + }); +} + +export function useDownloadInvoice() { + return useMutation({ + mutationFn: async (invoiceId: string) => { + const blob = await paymentService.downloadInvoice(invoiceId); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `invoice-${invoiceId}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + return true; + }, + }); +} + +// ============================================================================ +// Payment Methods Hooks +// ============================================================================ + +export function usePaymentMethods(enabled = true) { + return useQuery({ + queryKey: paymentKeys.paymentMethods(), + queryFn: () => paymentService.getPaymentMethods(), + enabled, + staleTime: 60000, // 1 minute + }); +} + +export function useAddPaymentMethod() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (paymentMethodId: string) => paymentService.addPaymentMethod(paymentMethodId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: paymentKeys.paymentMethods() }); + }, + }); +} + +export function useRemovePaymentMethod() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (paymentMethodId: string) => paymentService.removePaymentMethod(paymentMethodId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: paymentKeys.paymentMethods() }); + }, + }); +} + +export function useSetDefaultPaymentMethod() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (paymentMethodId: string) => paymentService.setDefaultPaymentMethod(paymentMethodId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: paymentKeys.paymentMethods() }); + }, + }); +} + +// ============================================================================ +// Refund Hooks +// ============================================================================ + +export interface UseRefundsOptions { + limit?: number; + offset?: number; + status?: string; + enabled?: boolean; +} + +export function useRefunds(options: UseRefundsOptions = {}) { + const { limit = 20, offset = 0, status, enabled = true } = options; + + return useQuery({ + queryKey: paymentKeys.refundsList(limit, offset, status), + queryFn: () => paymentService.getRefunds(limit, offset, status), + enabled, + staleTime: 30000, // 30 seconds + }); +} + +export function useRefund(refundId: string | undefined) { + return useQuery({ + queryKey: paymentKeys.refund(refundId ?? ''), + queryFn: () => paymentService.getRefundById(refundId!), + enabled: !!refundId, + staleTime: 60000, // 1 minute + }); +} + +export function useRefundEligibility(subscriptionId: string | undefined) { + return useQuery({ + queryKey: paymentKeys.refundEligibility(subscriptionId ?? ''), + queryFn: () => paymentService.getRefundEligibility(subscriptionId!), + enabled: !!subscriptionId, + staleTime: 60000, // 1 minute + }); +} + +export function useRequestRefund() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: RefundRequest) => paymentService.requestRefund(request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: paymentKeys.refunds() }); + }, + }); +} + +export function useCancelRefund() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (refundId: string) => paymentService.cancelRefund(refundId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: paymentKeys.refunds() }); + }, + }); +} + +// ============================================================================ +// Combined Hook for Billing Page +// ============================================================================ + +export function useBillingData() { + const invoicesQuery = useInvoices({ limit: 5 }); + const paymentMethodsQuery = usePaymentMethods(); + const refundsQuery = useRefunds({ limit: 5 }); + + return { + invoices: invoicesQuery.data?.invoices ?? [], + paymentMethods: paymentMethodsQuery.data ?? [], + refunds: refundsQuery.data?.refunds ?? [], + isLoading: + invoicesQuery.isLoading || paymentMethodsQuery.isLoading || refundsQuery.isLoading, + error: invoicesQuery.error || paymentMethodsQuery.error || refundsQuery.error, + refetch: () => { + invoicesQuery.refetch(); + paymentMethodsQuery.refetch(); + refundsQuery.refetch(); + }, + }; +} + +export default { + useInvoices, + useInvoice, + useDownloadInvoice, + usePaymentMethods, + useAddPaymentMethod, + useRemovePaymentMethod, + useSetDefaultPaymentMethod, + useRefunds, + useRefund, + useRefundEligibility, + useRequestRefund, + useCancelRefund, + useBillingData, +}; diff --git a/src/modules/assistant/components/AgentModeSelector.tsx b/src/modules/assistant/components/AgentModeSelector.tsx new file mode 100644 index 0000000..1c67da1 --- /dev/null +++ b/src/modules/assistant/components/AgentModeSelector.tsx @@ -0,0 +1,465 @@ +/** + * AgentModeSelector Component + * Toggle between proactive and reactive agent modes with configuration + * OQI-007: LLM Strategy Agent - Agent Mode Selection UI + */ + +import React, { useState, useCallback } from 'react'; +import { + BoltIcon, + HandRaisedIcon, + ClockIcon, + BellIcon, + ChartBarIcon, + ExclamationTriangleIcon, + InformationCircleIcon, + Cog6ToothIcon, + ChevronDownIcon, + ChevronUpIcon, + CheckIcon, + PlusIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import { BoltIcon as BoltSolidIcon, HandRaisedIcon as HandRaisedSolidIcon } from '@heroicons/react/24/solid'; + +// ============================================================================ +// Types +// ============================================================================ + +export type AgentMode = 'proactive' | 'reactive'; + +export interface TriggerCondition { + id: string; + type: 'price_alert' | 'signal_generated' | 'risk_threshold' | 'market_event' | 'schedule'; + name: string; + enabled: boolean; + config: Record; +} + +export interface ActivitySchedule { + id: string; + dayOfWeek: number; // 0-6 (Sunday-Saturday) + startTime: string; // HH:MM + endTime: string; // HH:MM + enabled: boolean; +} + +export interface AgentModeConfig { + mode: AgentMode; + triggers: TriggerCondition[]; + schedules: ActivitySchedule[]; + notifyOnAction: boolean; + requireConfirmation: boolean; + maxActionsPerHour: number; +} + +interface AgentModeSelectorProps { + config: AgentModeConfig; + onModeChange: (mode: AgentMode) => void; + onUpdateTrigger: (triggerId: string, updates: Partial) => void; + onAddTrigger?: () => void; + onRemoveTrigger?: (triggerId: string) => void; + onUpdateSchedule: (scheduleId: string, updates: Partial) => void; + onUpdateConfig: (updates: Partial) => void; + isLoading?: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const TRIGGER_TYPES: Record = { + price_alert: { + label: 'Alerta de Precio', + icon: ChartBarIcon, + description: 'Cuando un precio alcanza cierto nivel', + }, + signal_generated: { + label: 'Senal Generada', + icon: BoltIcon, + description: 'Cuando se genera una senal de trading', + }, + risk_threshold: { + label: 'Umbral de Riesgo', + icon: ExclamationTriangleIcon, + description: 'Cuando se supera un nivel de riesgo', + }, + market_event: { + label: 'Evento de Mercado', + icon: BellIcon, + description: 'Noticias o eventos importantes', + }, + schedule: { + label: 'Programado', + icon: ClockIcon, + description: 'En horarios especificos', + }, +}; + +const DAYS_OF_WEEK = [ + { value: 0, label: 'Dom' }, + { value: 1, label: 'Lun' }, + { value: 2, label: 'Mar' }, + { value: 3, label: 'Mie' }, + { value: 4, label: 'Jue' }, + { value: 5, label: 'Vie' }, + { value: 6, label: 'Sab' }, +]; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface ModeCardProps { + mode: AgentMode; + isSelected: boolean; + onSelect: () => void; +} + +const ModeCard: React.FC = ({ mode, isSelected, onSelect }) => { + const isProactive = mode === 'proactive'; + const Icon = isProactive ? (isSelected ? BoltSolidIcon : BoltIcon) : (isSelected ? HandRaisedSolidIcon : HandRaisedIcon); + + return ( + + ); +}; + +interface TriggerItemProps { + trigger: TriggerCondition; + onToggle: (enabled: boolean) => void; + onRemove?: () => void; +} + +const TriggerItem: React.FC = ({ trigger, onToggle, onRemove }) => { + const typeInfo = TRIGGER_TYPES[trigger.type]; + const Icon = typeInfo?.icon || BellIcon; + + return ( +
+
+ +
+
+

{trigger.name}

+

{typeInfo?.description}

+
+
+ {onRemove && ( + + )} + +
+
+ ); +}; + +interface ScheduleEditorProps { + schedules: ActivitySchedule[]; + onUpdate: (scheduleId: string, updates: Partial) => void; +} + +const ScheduleEditor: React.FC = ({ schedules, onUpdate }) => { + return ( +
+

Horarios de Actividad

+
+ {DAYS_OF_WEEK.map((day) => { + const schedule = schedules.find((s) => s.dayOfWeek === day.value); + return ( + + ); + })} +
+ {schedules.filter((s) => s.enabled).length > 0 && ( +
+ {schedules.filter((s) => s.enabled).map((schedule) => ( +
+ + {DAYS_OF_WEEK.find((d) => d.value === schedule.dayOfWeek)?.label} + + onUpdate(schedule.id, { startTime: e.target.value })} + className="px-2 py-1 text-xs bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded" + /> + a + onUpdate(schedule.id, { endTime: e.target.value })} + className="px-2 py-1 text-xs bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded" + /> +
+ ))} +
+ )} +
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const AgentModeSelector: React.FC = ({ + config, + onModeChange, + onUpdateTrigger, + onAddTrigger, + onRemoveTrigger, + onUpdateSchedule, + onUpdateConfig, + isLoading = false, +}) => { + const [showAdvanced, setShowAdvanced] = useState(false); + + const handleModeChange = useCallback((mode: AgentMode) => { + onModeChange(mode); + }, [onModeChange]); + + return ( +
+ {/* Header */} +
+
+ +

Modo del Agente

+
+

+ Configura como interactua el agente contigo +

+
+ + {/* Mode Selection */} +
+
+ handleModeChange('reactive')} + /> + handleModeChange('proactive')} + /> +
+
+ + {/* Mode Description */} +
+
+
+ +
+

+ Modo {config.mode === 'proactive' ? 'Proactivo' : 'Reactivo'} Activado +

+

+ {config.mode === 'proactive' + ? 'El agente te notificara sobre oportunidades, riesgos y eventos relevantes. Puede sugerir acciones automaticamente basado en los triggers configurados.' + : 'El agente solo respondera cuando le hagas preguntas. No enviara notificaciones ni tomara acciones por su cuenta.'} +

+
+
+
+
+ + {/* Proactive Mode Configuration */} + {config.mode === 'proactive' && ( + <> + {/* Triggers */} +
+
+

Triggers de Activacion

+ {onAddTrigger && ( + + )} +
+
+ {config.triggers.map((trigger) => ( + onUpdateTrigger(trigger.id, { enabled })} + onRemove={onRemoveTrigger ? () => onRemoveTrigger(trigger.id) : undefined} + /> + ))} +
+
+ + {/* Schedule */} +
+ +
+ + )} + + {/* Advanced Settings */} +
+ + + {showAdvanced && ( +
+ {/* Notify on Action */} +
+
+

Notificar al actuar

+

Recibir notificacion cuando el agente tome una accion

+
+ +
+ + {/* Require Confirmation */} +
+
+

Requerir confirmacion

+

Pedir confirmacion antes de ejecutar acciones criticas

+
+ +
+ + {/* Max Actions Per Hour */} +
+
+

Max acciones por hora

+ {config.maxActionsPerHour} +
+ onUpdateConfig({ maxActionsPerHour: parseInt(e.target.value) })} + className="w-full" + /> +
+ 1 + 20 +
+
+
+ )} +
+
+ ); +}; + +export default AgentModeSelector; diff --git a/src/modules/assistant/components/ConversationHistoryAdvanced.tsx b/src/modules/assistant/components/ConversationHistoryAdvanced.tsx new file mode 100644 index 0000000..406b536 --- /dev/null +++ b/src/modules/assistant/components/ConversationHistoryAdvanced.tsx @@ -0,0 +1,451 @@ +/** + * ConversationHistoryAdvanced Component + * Enhanced conversation history with date filters, search, and continue functionality + * OQI-007: LLM Strategy Agent - Conversation History UI + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { format, formatDistanceToNow, isWithinInterval, startOfDay, endOfDay, subDays } from 'date-fns'; +import { es } from 'date-fns/locale'; +import { + ChatBubbleLeftRightIcon, + PlusIcon, + TrashIcon, + MagnifyingGlassIcon, + CalendarIcon, + FunnelIcon, + ArrowPathIcon, + ChevronRightIcon, + XMarkIcon, + ArchiveBoxIcon, + ClockIcon, + SparklesIcon, +} from '@heroicons/react/24/outline'; +import { SparklesIcon as SparklesSolidIcon } from '@heroicons/react/24/solid'; +import type { ChatSession } from '../../../types/chat.types'; + +// ============================================================================ +// Types +// ============================================================================ + +type DateFilter = 'all' | 'today' | 'week' | 'month' | 'custom'; + +interface ConversationHistoryAdvancedProps { + sessions: ChatSession[]; + currentSessionId: string | null; + loading: boolean; + onSelectSession: (sessionId: string) => void; + onCreateSession: () => void; + onDeleteSession: (sessionId: string) => void; + onContinueSession?: (sessionId: string) => void; + onArchiveSession?: (sessionId: string) => void; + onExportSession?: (sessionId: string) => void; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +const getSessionTitle = (session: ChatSession): string => { + const firstUserMessage = session.messages.find((m) => m.role === 'user'); + if (firstUserMessage) { + return firstUserMessage.content.slice(0, 50) + (firstUserMessage.content.length > 50 ? '...' : ''); + } + return 'Nueva conversacion'; +}; + +const getLastMessagePreview = (session: ChatSession): string => { + const lastMessage = session.messages[session.messages.length - 1]; + if (lastMessage) { + const preview = lastMessage.content.slice(0, 60); + return preview + (lastMessage.content.length > 60 ? '...' : ''); + } + return 'Sin mensajes'; +}; + +const getDateFilterRange = (filter: DateFilter, customRange?: { start: Date; end: Date }) => { + const now = new Date(); + switch (filter) { + case 'today': + return { start: startOfDay(now), end: endOfDay(now) }; + case 'week': + return { start: startOfDay(subDays(now, 7)), end: endOfDay(now) }; + case 'month': + return { start: startOfDay(subDays(now, 30)), end: endOfDay(now) }; + case 'custom': + return customRange || null; + default: + return null; + } +}; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface DateFilterButtonsProps { + selected: DateFilter; + onSelect: (filter: DateFilter) => void; + onCustomClick: () => void; +} + +const DateFilterButtons: React.FC = ({ selected, onSelect, onCustomClick }) => { + const filters: { id: DateFilter; label: string }[] = [ + { id: 'all', label: 'Todo' }, + { id: 'today', label: 'Hoy' }, + { id: 'week', label: '7 dias' }, + { id: 'month', label: '30 dias' }, + { id: 'custom', label: 'Rango' }, + ]; + + return ( +
+ {filters.map((filter) => ( + + ))} +
+ ); +}; + +interface SessionListItemProps { + session: ChatSession; + isActive: boolean; + onSelect: () => void; + onDelete: () => void; + onContinue?: () => void; + onArchive?: () => void; +} + +const SessionListItem: React.FC = ({ + session, + isActive, + onSelect, + onDelete, + onContinue, + onArchive, +}) => { + const [showActions, setShowActions] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + if (confirmDelete) { + onDelete(); + setConfirmDelete(false); + } else { + setConfirmDelete(true); + setTimeout(() => setConfirmDelete(false), 3000); + } + }; + + return ( +
setShowActions(true)} + onMouseLeave={() => { setShowActions(false); setConfirmDelete(false); }} + className={`group relative p-3 rounded-lg cursor-pointer transition-all ${ + isActive + ? 'bg-primary-50 dark:bg-primary-900/30 border border-primary-200 dark:border-primary-800' + : 'hover:bg-gray-100 dark:hover:bg-gray-700/50 border border-transparent' + }`} + > +
+ {/* Icon */} +
+ {isActive ? : } +
+ + {/* Content */} +
+

+ {getSessionTitle(session)} +

+

+ {getLastMessagePreview(session)} +

+
+ + {formatDistanceToNow(new Date(session.updatedAt), { addSuffix: true, locale: es })} + + | + {session.messages.length} msg +
+
+ + {/* Actions */} +
+ {!confirmDelete ? ( + <> + {onContinue && ( + + )} + {onArchive && ( + + )} + + + ) : ( + <> + + + + )} +
+
+
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const ConversationHistoryAdvanced: React.FC = ({ + sessions, + currentSessionId, + loading, + onSelectSession, + onCreateSession, + onDeleteSession, + onContinueSession, + onArchiveSession, + onExportSession, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [dateFilter, setDateFilter] = useState('all'); + const [showFilters, setShowFilters] = useState(false); + const [customRange, setCustomRange] = useState<{ start: Date; end: Date } | null>(null); + const [showCustomDatePicker, setShowCustomDatePicker] = useState(false); + + const filteredSessions = useMemo(() => { + let filtered = sessions; + + // Search filter + if (searchTerm.trim()) { + const query = searchTerm.toLowerCase(); + filtered = filtered.filter((session) => { + const title = getSessionTitle(session).toLowerCase(); + const hasMatchInMessages = session.messages.some((m) => + m.content.toLowerCase().includes(query) + ); + return title.includes(query) || hasMatchInMessages; + }); + } + + // Date filter + const range = getDateFilterRange(dateFilter, customRange || undefined); + if (range) { + filtered = filtered.filter((session) => { + const date = new Date(session.updatedAt); + return isWithinInterval(date, { start: range.start, end: range.end }); + }); + } + + // Sort by most recent + return filtered.sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + }, [sessions, searchTerm, dateFilter, customRange]); + + const handleContinue = useCallback((sessionId: string) => { + onSelectSession(sessionId); + onContinueSession?.(sessionId); + }, [onSelectSession, onContinueSession]); + + const totalMessages = sessions.reduce((acc, s) => acc + s.messages.length, 0); + + return ( +
+ {/* Header with New Chat */} +
+ +
+ + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="Buscar en conversaciones..." + className="w-full pl-9 pr-10 py-2 text-sm bg-gray-100 dark:bg-gray-700 border-0 rounded-lg focus:ring-2 focus:ring-primary-500 text-gray-900 dark:text-white placeholder-gray-500" + /> + +
+ + {/* Filters */} + {showFilters && ( +
+
+ + Filtrar por fecha +
+ setShowCustomDatePicker(true)} + /> + {dateFilter === 'custom' && customRange && ( +
+ {format(customRange.start, 'dd/MM/yyyy', { locale: es })} + - + {format(customRange.end, 'dd/MM/yyyy', { locale: es })} + +
+ )} + {showCustomDatePicker && ( +
+
+ + { + const start = new Date(e.target.value); + setCustomRange((prev) => ({ start, end: prev?.end || new Date() })); + setDateFilter('custom'); + }} + className="w-full px-2 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded" + /> +
+
+ + { + const end = new Date(e.target.value); + setCustomRange((prev) => ({ start: prev?.start || subDays(new Date(), 30), end })); + setDateFilter('custom'); + }} + className="w-full px-2 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded" + /> +
+
+ )} +
+ )} +
+ + {/* Results Summary */} + {(searchTerm || dateFilter !== 'all') && ( +
+

+ {filteredSessions.length} de {sessions.length} conversaciones +

+
+ )} + + {/* Session List */} +
+ {loading && sessions.length === 0 ? ( +
+ +

Cargando conversaciones...

+
+ ) : filteredSessions.length === 0 ? ( +
+
+ +
+

+ {searchTerm || dateFilter !== 'all' ? 'No se encontraron resultados' : 'Sin conversaciones'} +

+

+ {searchTerm || dateFilter !== 'all' ? 'Intenta con otros terminos o filtros' : 'Inicia una nueva conversacion'} +

+
+ ) : ( +
+ {filteredSessions.map((session) => ( + onSelectSession(session.id)} + onDelete={() => onDeleteSession(session.id)} + onContinue={onContinueSession ? () => handleContinue(session.id) : undefined} + onArchive={onArchiveSession ? () => onArchiveSession(session.id) : undefined} + /> + ))} +
+ )} +
+ + {/* Footer Stats */} +
+
+ {sessions.length} conversaciones + {totalMessages.toLocaleString()} mensajes totales +
+
+
+ ); +}; + +export default ConversationHistoryAdvanced; diff --git a/src/modules/assistant/components/MemoryManagerPanel.tsx b/src/modules/assistant/components/MemoryManagerPanel.tsx new file mode 100644 index 0000000..faa0647 --- /dev/null +++ b/src/modules/assistant/components/MemoryManagerPanel.tsx @@ -0,0 +1,453 @@ +/** + * MemoryManagerPanel Component + * Enhanced memory management with categories, edit/delete, and API integration + * OQI-007: LLM Strategy Agent - Memory Management UI + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { + CircleStackIcon, + PencilIcon, + TrashIcon, + PlusIcon, + FolderIcon, + LightBulbIcon, + ChartBarIcon, + ClockIcon, + CheckIcon, + XMarkIcon, + MagnifyingGlassIcon, + ArrowPathIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; + +// ============================================================================ +// Types +// ============================================================================ + +export type MemoryCategory = 'preferences' | 'strategies' | 'history' | 'custom'; + +export interface MemoryItem { + id: string; + category: MemoryCategory; + key: string; + value: string; + metadata?: Record; + createdAt: string; + updatedAt: string; + isPinned?: boolean; +} + +export interface MemoryStats { + totalItems: number; + totalTokens: number; + maxTokens: number; + byCategory: Record; +} + +interface MemoryManagerPanelProps { + memories: MemoryItem[]; + stats: MemoryStats; + onAddMemory: (memory: Omit) => Promise; + onUpdateMemory: (id: string, updates: Partial) => Promise; + onDeleteMemory: (id: string) => Promise; + onClearCategory?: (category: MemoryCategory) => Promise; + onRefresh?: () => Promise; + isLoading?: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const CATEGORY_INFO: Record = { + preferences: { label: 'Preferencias', icon: LightBulbIcon, color: 'text-amber-500 bg-amber-500/10' }, + strategies: { label: 'Estrategias', icon: ChartBarIcon, color: 'text-blue-500 bg-blue-500/10' }, + history: { label: 'Historial', icon: ClockIcon, color: 'text-purple-500 bg-purple-500/10' }, + custom: { label: 'Personalizado', icon: FolderIcon, color: 'text-gray-500 bg-gray-500/10' }, +}; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface MemoryItemCardProps { + memory: MemoryItem; + onEdit: () => void; + onDelete: () => void; + isEditing: boolean; + editValue: string; + onEditChange: (value: string) => void; + onSave: () => void; + onCancel: () => void; +} + +const MemoryItemCard: React.FC = ({ + memory, + onEdit, + onDelete, + isEditing, + editValue, + onEditChange, + onSave, + onCancel, +}) => { + const [confirmDelete, setConfirmDelete] = useState(false); + const category = CATEGORY_INFO[memory.category]; + const CategoryIcon = category.icon; + + return ( +
+
+
+ +
+
+
+ {memory.key} + {memory.isPinned && ( + + Fijado + + )} +
+ {isEditing ? ( +
+