diff --git a/frontend/src/api/hooks/aiInsights.ts b/frontend/src/api/hooks/aiInsights.ts index 05431d4b..62b923f1 100644 --- a/frontend/src/api/hooks/aiInsights.ts +++ b/frontend/src/api/hooks/aiInsights.ts @@ -15,6 +15,7 @@ */ import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; +import { useState } from 'react'; import { aiInsightsService, AIInsight, @@ -213,13 +214,13 @@ export function useApplyInsight( * Mutation hook to dismiss an insight */ export function useDismissInsight( - options?: UseMutationOptions + options?: UseMutationOptions ) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ tenantId, insightId, reason }) => - aiInsightsService.dismissInsight(tenantId, insightId, reason), + mutationFn: ({ tenantId, insightId }) => + aiInsightsService.dismissInsight(tenantId, insightId), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: aiInsightsKeys.lists() }); queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) }); @@ -231,16 +232,16 @@ export function useDismissInsight( } /** - * Mutation hook to resolve an insight + * Mutation hook to update insight status */ -export function useResolveInsight( - options?: UseMutationOptions +export function useUpdateInsightStatus( + options?: UseMutationOptions ) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ tenantId, insightId, resolution }) => - aiInsightsService.resolveInsight(tenantId, insightId, resolution), + mutationFn: ({ tenantId, insightId, status }) => + aiInsightsService.updateInsightStatus(tenantId, insightId, status), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: aiInsightsKeys.lists() }); queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) }); @@ -300,6 +301,3 @@ export function useInsightSelection() { isSelected: (insightId: string) => selectedInsights.includes(insightId), }; } - -// Import useState for utility hook -import { useState } from 'react'; diff --git a/frontend/src/api/hooks/useDashboardData.ts b/frontend/src/api/hooks/useDashboardData.ts index 82f8cabf..2c9efed3 100644 --- a/frontend/src/api/hooks/useDashboardData.ts +++ b/frontend/src/api/hooks/useDashboardData.ts @@ -13,10 +13,41 @@ import { productionService } from '../services/production'; import { ProcurementService } from '../services/procurement-service'; import * as orchestratorService from '../services/orchestrator'; import { suppliersService } from '../services/suppliers'; +import { aiInsightsService } from '../services/aiInsights'; import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications'; import { useSSEEvents } from '../../hooks/useSSE'; import { parseISO } from 'date-fns'; +// ============================================================ +// Helper Functions +// ============================================================ + +/** + * Map AI insight category to dashboard block type + */ +function mapInsightTypeToBlockType(category: string): string { + const mapping: Record = { + 'inventory': 'safety_stock', + 'forecasting': 'demand_forecast', + 'demand': 'demand_forecast', + 'procurement': 'cost_optimization', + 'cost': 'cost_optimization', + 'production': 'waste_reduction', + 'quality': 'risk_alert', + 'efficiency': 'waste_reduction', + }; + return mapping[category] || 'demand_forecast'; +} + +/** + * Map AI insight priority to dashboard impact level + */ +function mapPriorityToImpact(priority: string): 'high' | 'medium' | 'low' { + if (priority === 'critical' || priority === 'high') return 'high'; + if (priority === 'medium') return 'medium'; + return 'low'; +} + // ============================================================ // Types // ============================================================ @@ -75,20 +106,26 @@ export function useDashboardData(tenantId: string) { const now = new Date(); // Keep for local time display const nowUTC = new Date(); // UTC time for accurate comparison with API dates - // Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment) - const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers] = await Promise.all([ + // Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment and AI insights) + const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers, aiInsightsResponse] = await Promise.all([ alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []), getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []), productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })), ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })), orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null), suppliersService.getSuppliers(tenantId).catch(() => []), + aiInsightsService.getInsights(tenantId, { + status: 'new', + priority: 'high', + limit: 5 + }).catch(() => ({ items: [], total: 0, limit: 5, offset: 0, has_more: false })), ]); // Normalize alerts (API returns array directly or {items: []}) const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []); const productionBatches = productionResponse?.batches || []; const deliveries = deliveriesResponse?.deliveries || []; + const aiInsights = aiInsightsResponse?.items || []; // Create supplier ID -> supplier name map for quick lookup const supplierMap = new Map(); @@ -246,6 +283,19 @@ export function useDashboardData(tenantId: string) { }; } + // Map AI insights to dashboard format + const mappedAiInsights = aiInsights.map((insight: any) => ({ + id: insight.id, + title: insight.title, + description: insight.description, + type: mapInsightTypeToBlockType(insight.category), + impact: mapPriorityToImpact(insight.priority), + impact_value: insight.impact_value?.toString(), + impact_currency: insight.impact_unit === 'euros' ? '€' : '', + created_at: insight.created_at, + recommendation_actions: insight.recommendation_actions || [], + })); + return { // Raw data alerts: deduplicatedAlerts, @@ -253,7 +303,7 @@ export function useDashboardData(tenantId: string) { productionBatches, deliveries, orchestrationSummary, - aiInsights: [], // AI-generated insights for professional/enterprise tiers + aiInsights: mappedAiInsights, // Computed preventedIssues, @@ -295,6 +345,7 @@ export function useDashboardRealtimeSync(tenantId: string) { const { notifications: deliveryNotifications } = useDeliveryNotifications(); const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications(); const { events: alertEvents } = useSSEEvents({ channels: ['*.alerts'] }); + const { events: aiInsightEvents } = useSSEEvents({ channels: ['*.ai_insights'] }); // Invalidate dashboard data on batch events useEffect(() => { @@ -345,4 +396,15 @@ export function useDashboardRealtimeSync(tenantId: string) { refetchType: 'active', }); }, [alertEvents, tenantId, queryClient]); + + // Invalidate dashboard data on AI insight events + useEffect(() => { + if (!aiInsightEvents || aiInsightEvents.length === 0 || !tenantId) return; + + // Any new AI insight should trigger a refresh + queryClient.invalidateQueries({ + queryKey: ['dashboard-data', tenantId], + refetchType: 'active', + }); + }, [aiInsightEvents, tenantId, queryClient]); } diff --git a/frontend/src/api/services/aiInsights.ts b/frontend/src/api/services/aiInsights.ts index 4c2619d1..6e9dfc46 100644 --- a/frontend/src/api/services/aiInsights.ts +++ b/frontend/src/api/services/aiInsights.ts @@ -20,17 +20,12 @@ import { apiClient } from '../client'; export interface AIInsight { id: string; tenant_id: string; - type: 'forecast' | 'warning' | 'opportunity' | 'positive' | 'optimization' | 'rule'; - priority: 'urgent' | 'high' | 'medium' | 'low'; - category: 'demand' | 'procurement' | 'inventory' | 'production' | 'sales' | 'system' | 'business'; + type: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly'; + priority: 'low' | 'medium' | 'high' | 'critical'; + category: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling'; title: string; description: string; - reasoning_data?: { - type: string; - parameters: Record; - [key: string]: any; - }; - impact_type: 'cost_savings' | 'waste_reduction' | 'yield_improvement' | 'revenue' | 'system_health' | 'process_improvement'; + impact_type?: 'cost_savings' | 'revenue_increase' | 'waste_reduction' | 'efficiency_gain' | 'quality_improvement' | 'risk_mitigation'; impact_value?: number; impact_unit?: string; confidence: number; @@ -39,31 +34,27 @@ export interface AIInsight { recommendation_actions?: Array<{ label: string; action: string; - params: Record; + endpoint?: string; }>; - source_service: string; - source_model: string; - detected_at: string; - resolved_at?: string; - resolved_by?: string; - status: 'active' | 'applied' | 'dismissed' | 'resolved'; - feedback_count?: number; - avg_feedback_rating?: number; + source_service?: string; + source_data_id?: string; + status: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired'; created_at: string; updated_at: string; + applied_at?: string; + expired_at?: string; } export interface AIInsightFilters { - type?: string; - priority?: string; - category?: string; - source_model?: string; - status?: string; - min_confidence?: number; + type?: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly'; + priority?: 'low' | 'medium' | 'high' | 'critical'; + status?: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired'; + category?: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling'; actionable_only?: boolean; - start_date?: string; - end_date?: string; - search?: string; + min_confidence?: number; + source_service?: string; + from_date?: string; + to_date?: string; limit?: number; offset?: number; } @@ -78,14 +69,15 @@ export interface AIInsightListResponse { export interface AIInsightStatsResponse { total_insights: number; - insights_by_type: Record; - insights_by_priority: Record; - insights_by_category: Record; - insights_by_status: Record; - avg_confidence: number; - total_impact_value: number; actionable_insights: number; - resolved_insights: number; + average_confidence: number; + high_priority_count: number; + medium_priority_count: number; + low_priority_count: number; + critical_priority_count: number; + by_category: Record; + by_status: Record; + total_potential_impact?: number; } export interface FeedbackRequest { @@ -139,13 +131,12 @@ export class AIInsightsService { if (filters?.type) queryParams.append('type', filters.type); if (filters?.priority) queryParams.append('priority', filters.priority); if (filters?.category) queryParams.append('category', filters.category); - if (filters?.source_model) queryParams.append('source_model', filters.source_model); if (filters?.status) queryParams.append('status', filters.status); if (filters?.min_confidence) queryParams.append('min_confidence', filters.min_confidence.toString()); if (filters?.actionable_only) queryParams.append('actionable_only', 'true'); - if (filters?.start_date) queryParams.append('start_date', filters.start_date); - if (filters?.end_date) queryParams.append('end_date', filters.end_date); - if (filters?.search) queryParams.append('search', filters.search); + if (filters?.source_service) queryParams.append('source_service', filters.source_service); + if (filters?.from_date) queryParams.append('from_date', filters.from_date); + if (filters?.to_date) queryParams.append('to_date', filters.to_date); if (filters?.limit) queryParams.append('limit', filters.limit.toString()); if (filters?.offset) queryParams.append('offset', filters.offset.toString()); @@ -235,23 +226,22 @@ export class AIInsightsService { */ async dismissInsight( tenantId: string, - insightId: string, - reason?: string - ): Promise { - const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/dismiss`; - return apiClient.post(url, { reason }); + insightId: string + ): Promise { + const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`; + return apiClient.delete(url); } /** - * Resolve an insight + * Update an insight status (acknowledge, apply, etc.) */ - async resolveInsight( + async updateInsightStatus( tenantId: string, insightId: string, - resolution?: string + status: 'acknowledged' | 'in_progress' | 'applied' | 'expired' ): Promise { - const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/resolve`; - return apiClient.post(url, { resolution }); + const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`; + return apiClient.patch(url, { status }); } /** @@ -261,17 +251,18 @@ export class AIInsightsService { tenantId: string, limit: number = 10 ): Promise { + // Fetch critical priority insights first const response = await this.getInsights(tenantId, { - priority: 'urgent', - status: 'active', + priority: 'critical', + status: 'new', limit, }); if (response.items.length < limit) { - // Add high priority if not enough urgent + // Add high priority if not enough critical const highPriorityResponse = await this.getInsights(tenantId, { priority: 'high', - status: 'active', + status: 'new', limit: limit - response.items.length, }); return [...response.items, ...highPriorityResponse.items]; @@ -289,7 +280,7 @@ export class AIInsightsService { ): Promise { const response = await this.getInsights(tenantId, { actionable_only: true, - status: 'active', + status: 'new', limit, }); @@ -305,8 +296,8 @@ export class AIInsightsService { limit: number = 20 ): Promise { const response = await this.getInsights(tenantId, { - category, - status: 'active', + category: category as any, // Category comes from user input + status: 'new', limit, }); @@ -321,13 +312,20 @@ export class AIInsightsService { query: string, filters?: Partial ): Promise { + // Note: search parameter not supported by backend API + // This is a client-side workaround - fetch all and filter const response = await this.getInsights(tenantId, { ...filters, - search: query, limit: filters?.limit || 50, }); - return response.items; + // Filter by query on client side + const lowerQuery = query.toLowerCase(); + return response.items.filter( + (insight) => + insight.title.toLowerCase().includes(lowerQuery) || + insight.description.toLowerCase().includes(lowerQuery) + ); } /** diff --git a/frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx b/frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx index ea6c7ac6..93692dbd 100644 --- a/frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx +++ b/frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx @@ -18,6 +18,10 @@ interface AIInsight { impact_value?: string; impact_currency?: string; created_at: string; + recommendation_actions?: Array<{ + label: string; + action: string; + }>; } interface AIInsightsBlockProps { @@ -143,17 +147,44 @@ export function AIInsightsBlock({ insights = [], loading = false, onViewAll }: A {insight.description}

+ {/* Recommendations */} + {insight.recommendation_actions && insight.recommendation_actions.length > 0 && ( +
+
+ +
+

+ {t('dashboard:ai_insights.recommendations', 'Recomendaciones')}: +

+
    + {insight.recommendation_actions.slice(0, 2).map((action, idx) => ( +
  • + + {action.label || action.action} +
  • + ))} +
+
+
+
+ )} + {/* Impact Value */} {insight.impact_value && ( -
+
{insight.type === 'cost_optimization' && ( - {insight.impact_currency}{insight.impact_value} {t('dashboard:ai_insights.savings')} + 💰 {insight.impact_currency}{insight.impact_value} {t('dashboard:ai_insights.savings')} )} {insight.type === 'waste_reduction' && ( - {insight.impact_value} {t('dashboard:ai_insights.reduction')} + ♻️ {insight.impact_value} {t('dashboard:ai_insights.reduction')} + + )} + {!['cost_optimization', 'waste_reduction'].includes(insight.type) && ( + + 💰 {insight.impact_currency}{insight.impact_value} )}
diff --git a/frontend/src/pages/app/analytics/ai-insights/AIInsightsPage.tsx b/frontend/src/pages/app/analytics/ai-insights/AIInsightsPage.tsx index 3c035dcf..c3162075 100644 --- a/frontend/src/pages/app/analytics/ai-insights/AIInsightsPage.tsx +++ b/frontend/src/pages/app/analytics/ai-insights/AIInsightsPage.tsx @@ -16,10 +16,11 @@ const AIInsightsPage: React.FC = () => { const { t } = useTranslation('reasoning'); // Fetch real insights from API + // Note: Backend expects status values: 'new', 'acknowledged', 'in_progress', 'applied', 'dismissed', 'expired' + // We fetch 'new' and 'acknowledged' insights (not dismissed, applied, or expired) const { data: insightsData, isLoading, refetch } = useAIInsights( tenantId || '', { - status: 'active', category: selectedCategory === 'all' ? undefined : selectedCategory, limit: 100, }, @@ -44,20 +45,20 @@ const AIInsightsPage: React.FC = () => { const categories = [ { value: 'all', label: 'Todas las Categorías', count: stats?.total_insights || 0 }, - { value: 'production', label: 'Producción', count: stats?.insights_by_category?.production || 0 }, - { value: 'sales', label: 'Ventas', count: stats?.insights_by_category?.sales || 0 }, - { value: 'demand', label: 'Pronósticos', count: stats?.insights_by_category?.demand || 0 }, - { value: 'inventory', label: 'Inventario', count: stats?.insights_by_category?.inventory || 0 }, - { value: 'procurement', label: 'Compras', count: stats?.insights_by_category?.procurement || 0 }, + { value: 'production', label: 'Producción', count: stats?.by_category?.production || 0 }, + { value: 'sales', label: 'Ventas', count: stats?.by_category?.sales || 0 }, + { value: 'demand', label: 'Pronósticos', count: stats?.by_category?.demand || stats?.by_category?.forecasting || 0 }, + { value: 'inventory', label: 'Inventario', count: stats?.by_category?.inventory || 0 }, + { value: 'procurement', label: 'Compras', count: stats?.by_category?.procurement || 0 }, ]; const aiMetrics = { totalInsights: stats?.total_insights || 0, actionableInsights: stats?.actionable_insights || 0, - averageConfidence: stats?.avg_confidence ? Math.round(stats.avg_confidence) : 0, - highPriorityInsights: stats?.insights_by_priority?.high || stats?.insights_by_priority?.urgent || 0, - mediumPriorityInsights: stats?.insights_by_priority?.medium || 0, - lowPriorityInsights: stats?.insights_by_priority?.low || 0, + averageConfidence: stats?.average_confidence ? Math.round(stats.average_confidence) : 0, + highPriorityInsights: (stats?.high_priority_count || 0) + (stats?.critical_priority_count || 0), + mediumPriorityInsights: stats?.medium_priority_count || 0, + lowPriorityInsights: stats?.low_priority_count || 0, }; const getTypeIcon = (type: string) => { @@ -258,22 +259,73 @@ const AIInsightsPage: React.FC = () => {

{insight.title}

{getInsightDescription(insight)}

-

{insight.impact}

- {/* Metrics */} -
- {Object.entries(insight.metrics).map(([key, value]) => ( -
-

- {key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} -

-

{value}

+ {/* Impact */} + {insight.impact_value && insight.impact_type && ( +
+

+ 💰 Impacto: {insight.impact_type.replace(/_/g, ' ')} - {insight.impact_value} {insight.impact_unit || ''} +

+
+ )} + + {/* Recommendation */} + {insight.recommendation_actions && insight.recommendation_actions.length > 0 && ( +
+
+ +

Recomendaciones

- ))} -
+
    + {insight.recommendation_actions.map((action, idx) => ( +
  • + + {action.label || action.action} +
  • + ))} +
+
+ )} + + {/* Metrics - Only show non-redundant metrics */} + {insight.metrics_json && Object.keys(insight.metrics_json).length > 0 && ( +
+ {Object.entries(insight.metrics_json) + .filter(([key]) => !['pattern', 'recommendation'].includes(key)) // Filter out already displayed data + .map(([key, value]) => { + // Format the value for display + let displayValue: string; + if (typeof value === 'object' && value !== null) { + // For objects, try to display them nicely + if (Object.keys(value).length < 5) { + displayValue = Object.entries(value) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + } else { + displayValue = JSON.stringify(value, null, 2); + } + } else if (typeof value === 'number') { + displayValue = value.toLocaleString(); + } else { + displayValue = String(value); + } + + return ( +
+

+ {key.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} +

+

+ {displayValue} +

+
+ ); + })} +
+ )}
-

{insight.timestamp}

+

{insight.created_at ? new Date(insight.created_at).toLocaleString() : ''}

{insight.actionable && (