Add AI insights feature
This commit is contained in:
@@ -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<AIInsight, Error, { tenantId: string; insightId: string; reason?: string }>
|
||||
options?: UseMutationOptions<void, Error, { tenantId: string; insightId: string }>
|
||||
) {
|
||||
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<AIInsight, Error, { tenantId: string; insightId: string; resolution?: string }>
|
||||
export function useUpdateInsightStatus(
|
||||
options?: UseMutationOptions<AIInsight, Error, { tenantId: string; insightId: string; status: 'acknowledged' | 'in_progress' | 'applied' | 'expired' }>
|
||||
) {
|
||||
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';
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<string, string>();
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<string, any>;
|
||||
[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<string, any>;
|
||||
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<string, number>;
|
||||
insights_by_priority: Record<string, number>;
|
||||
insights_by_category: Record<string, number>;
|
||||
insights_by_status: Record<string, number>;
|
||||
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<string, number>;
|
||||
by_status: Record<string, number>;
|
||||
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<AIInsight> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/dismiss`;
|
||||
return apiClient.post<AIInsight>(url, { reason });
|
||||
insightId: string
|
||||
): Promise<void> {
|
||||
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<AIInsight> {
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/resolve`;
|
||||
return apiClient.post<AIInsight>(url, { resolution });
|
||||
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
|
||||
return apiClient.patch<AIInsight>(url, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,17 +251,18 @@ export class AIInsightsService {
|
||||
tenantId: string,
|
||||
limit: number = 10
|
||||
): Promise<AIInsight[]> {
|
||||
// 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<AIInsight[]> {
|
||||
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<AIInsight[]> {
|
||||
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<AIInsightFilters>
|
||||
): Promise<AIInsight[]> {
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user