Add AI insights feature

This commit is contained in:
Urtzi Alfaro
2025-12-15 21:14:22 +01:00
parent 5642b5a0c0
commit c566967bea
39 changed files with 17729 additions and 404 deletions

View File

@@ -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';

View File

@@ -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]);
}