New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -12,7 +12,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://fonts.googleapis.com https://js.stripe.com; script-src-elem 'self' 'unsafe-inline' https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' http://localhost http://localhost:8000 http://localhost:8006 ws: wss:; frame-src https://js.stripe.com;" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://fonts.googleapis.com https://js.stripe.com; script-src-elem 'self' 'unsafe-inline' https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' http://localhost http://localhost:8000 http://localhost:8001 http://localhost:8006 ws: wss:; frame-src https://js.stripe.com;" always;
# Gzip compression
gzip on;

View File

@@ -11,18 +11,22 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { SSEProvider } from './contexts/SSEContext';
import { SubscriptionEventsProvider } from './contexts/SubscriptionEventsContext';
import { EnterpriseProvider } from './contexts/EnterpriseContext';
import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler';
import { CookieBanner } from './components/ui/CookieConsent';
import { useTenantInitializer } from './stores/useTenantInitializer';
import i18n from './i18n';
// PHASE 1 OPTIMIZATION: Optimized React Query configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 3,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 2, // Reduced from 3 to 2 for faster failure
refetchOnWindowFocus: true, // Changed to true for better UX
refetchOnMount: 'stale', // Only refetch if data is stale (not always)
structuralSharing: true, // Enable request deduplication
},
},
});
@@ -69,7 +73,9 @@ function App() {
<AuthProvider>
<SSEProvider>
<SubscriptionEventsProvider>
<AppContent />
<EnterpriseProvider>
<AppContent />
</EnterpriseProvider>
</SubscriptionEventsProvider>
</SSEProvider>
</AuthProvider>

View File

@@ -1,545 +0,0 @@
/**
* Alert Processor React Query hooks
* Provides data fetching, caching, and state management for alert processing operations
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { alertProcessorService } from '../services/alert_processor';
import { ApiError } from '../client/apiClient';
import type {
AlertResponse,
AlertUpdateRequest,
AlertQueryParams,
AlertDashboardData,
NotificationSettings,
ChannelRoutingConfig,
WebhookConfig,
AlertProcessingStatus,
ProcessingMetrics,
PaginatedResponse,
} from '../types/alert_processor';
// Query Keys Factory
export const alertProcessorKeys = {
all: ['alert-processor'] as const,
alerts: {
all: () => [...alertProcessorKeys.all, 'alerts'] as const,
lists: () => [...alertProcessorKeys.alerts.all(), 'list'] as const,
list: (tenantId: string, params?: AlertQueryParams) =>
[...alertProcessorKeys.alerts.lists(), tenantId, params] as const,
details: () => [...alertProcessorKeys.alerts.all(), 'detail'] as const,
detail: (tenantId: string, alertId: string) =>
[...alertProcessorKeys.alerts.details(), tenantId, alertId] as const,
dashboard: (tenantId: string) =>
[...alertProcessorKeys.alerts.all(), 'dashboard', tenantId] as const,
processingStatus: (tenantId: string, alertId: string) =>
[...alertProcessorKeys.alerts.all(), 'processing-status', tenantId, alertId] as const,
},
notifications: {
all: () => [...alertProcessorKeys.all, 'notifications'] as const,
settings: (tenantId: string) =>
[...alertProcessorKeys.notifications.all(), 'settings', tenantId] as const,
routing: () =>
[...alertProcessorKeys.notifications.all(), 'routing-config'] as const,
},
webhooks: {
all: () => [...alertProcessorKeys.all, 'webhooks'] as const,
list: (tenantId: string) =>
[...alertProcessorKeys.webhooks.all(), tenantId] as const,
},
metrics: {
all: () => [...alertProcessorKeys.all, 'metrics'] as const,
processing: (tenantId: string) =>
[...alertProcessorKeys.metrics.all(), 'processing', tenantId] as const,
},
} as const;
// Alert Queries
export const useAlerts = (
tenantId: string,
queryParams?: AlertQueryParams,
options?: Omit<UseQueryOptions<PaginatedResponse<AlertResponse>, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PaginatedResponse<AlertResponse>, ApiError>({
queryKey: alertProcessorKeys.alerts.list(tenantId, queryParams),
queryFn: () => alertProcessorService.getAlerts(tenantId, queryParams),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
export const useAlert = (
tenantId: string,
alertId: string,
options?: Omit<UseQueryOptions<AlertResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<AlertResponse, ApiError>({
queryKey: alertProcessorKeys.alerts.detail(tenantId, alertId),
queryFn: () => alertProcessorService.getAlert(tenantId, alertId),
enabled: !!tenantId && !!alertId,
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
export const useAlertDashboardData = (
tenantId: string,
options?: Omit<UseQueryOptions<AlertDashboardData, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<AlertDashboardData, ApiError>({
queryKey: alertProcessorKeys.alerts.dashboard(tenantId),
queryFn: () => alertProcessorService.getDashboardData(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 1 * 60 * 1000, // Refresh every minute
...options,
});
};
export const useAlertProcessingStatus = (
tenantId: string,
alertId: string,
options?: Omit<UseQueryOptions<AlertProcessingStatus, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<AlertProcessingStatus, ApiError>({
queryKey: alertProcessorKeys.alerts.processingStatus(tenantId, alertId),
queryFn: () => alertProcessorService.getProcessingStatus(tenantId, alertId),
enabled: !!tenantId && !!alertId,
staleTime: 10 * 1000, // 10 seconds
refetchInterval: 30 * 1000, // Poll every 30 seconds
...options,
});
};
// Notification Queries
export const useNotificationSettings = (
tenantId: string,
options?: Omit<UseQueryOptions<NotificationSettings, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<NotificationSettings, ApiError>({
queryKey: alertProcessorKeys.notifications.settings(tenantId),
queryFn: () => alertProcessorService.getNotificationSettings(tenantId),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useChannelRoutingConfig = (
options?: Omit<UseQueryOptions<ChannelRoutingConfig, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ChannelRoutingConfig, ApiError>({
queryKey: alertProcessorKeys.notifications.routing(),
queryFn: () => alertProcessorService.getChannelRoutingConfig(),
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
// Webhook Queries
export const useWebhooks = (
tenantId: string,
options?: Omit<UseQueryOptions<WebhookConfig[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<WebhookConfig[], ApiError>({
queryKey: alertProcessorKeys.webhooks.list(tenantId),
queryFn: () => alertProcessorService.getWebhooks(tenantId),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
// Metrics Queries
export const useProcessingMetrics = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcessingMetrics, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcessingMetrics, ApiError>({
queryKey: alertProcessorKeys.metrics.processing(tenantId),
queryFn: () => alertProcessorService.getProcessingMetrics(tenantId),
enabled: !!tenantId,
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
// Alert Mutations
export const useUpdateAlert = (
options?: UseMutationOptions<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string; updateData: AlertUpdateRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string; updateData: AlertUpdateRequest }
>({
mutationFn: ({ tenantId, alertId, updateData }) =>
alertProcessorService.updateAlert(tenantId, alertId, updateData),
onSuccess: (data, { tenantId, alertId }) => {
// Update the alert in cache
queryClient.setQueryData(
alertProcessorKeys.alerts.detail(tenantId, alertId),
data
);
// Invalidate alerts list to reflect the change
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.lists()
});
// Invalidate dashboard data
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.dashboard(tenantId)
});
},
...options,
});
};
export const useDismissAlert = (
options?: UseMutationOptions<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string }
>({
mutationFn: ({ tenantId, alertId }) =>
alertProcessorService.dismissAlert(tenantId, alertId),
onSuccess: (data, { tenantId, alertId }) => {
// Update the alert in cache
queryClient.setQueryData(
alertProcessorKeys.alerts.detail(tenantId, alertId),
data
);
// Invalidate related queries
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.lists()
});
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.dashboard(tenantId)
});
},
...options,
});
};
export const useAcknowledgeAlert = (
options?: UseMutationOptions<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string; notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string; notes?: string }
>({
mutationFn: ({ tenantId, alertId, notes }) =>
alertProcessorService.acknowledgeAlert(tenantId, alertId, notes),
onSuccess: (data, { tenantId, alertId }) => {
// Update the alert in cache
queryClient.setQueryData(
alertProcessorKeys.alerts.detail(tenantId, alertId),
data
);
// Invalidate related queries
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.lists()
});
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.dashboard(tenantId)
});
},
...options,
});
};
export const useResolveAlert = (
options?: UseMutationOptions<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string; notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
AlertResponse,
ApiError,
{ tenantId: string; alertId: string; notes?: string }
>({
mutationFn: ({ tenantId, alertId, notes }) =>
alertProcessorService.resolveAlert(tenantId, alertId, notes),
onSuccess: (data, { tenantId, alertId }) => {
// Update the alert in cache
queryClient.setQueryData(
alertProcessorKeys.alerts.detail(tenantId, alertId),
data
);
// Invalidate related queries
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.lists()
});
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.dashboard(tenantId)
});
},
...options,
});
};
// Notification Settings Mutations
export const useUpdateNotificationSettings = (
options?: UseMutationOptions<
NotificationSettings,
ApiError,
{ tenantId: string; settings: Partial<NotificationSettings> }
>
) => {
const queryClient = useQueryClient();
return useMutation<
NotificationSettings,
ApiError,
{ tenantId: string; settings: Partial<NotificationSettings> }
>({
mutationFn: ({ tenantId, settings }) =>
alertProcessorService.updateNotificationSettings(tenantId, settings),
onSuccess: (data, { tenantId }) => {
// Update settings in cache
queryClient.setQueryData(
alertProcessorKeys.notifications.settings(tenantId),
data
);
},
...options,
});
};
// Webhook Mutations
export const useCreateWebhook = (
options?: UseMutationOptions<
WebhookConfig,
ApiError,
{ tenantId: string; webhook: Omit<WebhookConfig, 'tenant_id'> }
>
) => {
const queryClient = useQueryClient();
return useMutation<
WebhookConfig,
ApiError,
{ tenantId: string; webhook: Omit<WebhookConfig, 'tenant_id'> }
>({
mutationFn: ({ tenantId, webhook }) =>
alertProcessorService.createWebhook(tenantId, webhook),
onSuccess: (data, { tenantId }) => {
// Add the new webhook to the list
queryClient.setQueryData(
alertProcessorKeys.webhooks.list(tenantId),
(oldData: WebhookConfig[] | undefined) => [...(oldData || []), data]
);
},
...options,
});
};
export const useUpdateWebhook = (
options?: UseMutationOptions<
WebhookConfig,
ApiError,
{ tenantId: string; webhookId: string; webhook: Partial<WebhookConfig> }
>
) => {
const queryClient = useQueryClient();
return useMutation<
WebhookConfig,
ApiError,
{ tenantId: string; webhookId: string; webhook: Partial<WebhookConfig> }
>({
mutationFn: ({ tenantId, webhookId, webhook }) =>
alertProcessorService.updateWebhook(tenantId, webhookId, webhook),
onSuccess: (data, { tenantId }) => {
// Update the webhook in the list
queryClient.setQueryData(
alertProcessorKeys.webhooks.list(tenantId),
(oldData: WebhookConfig[] | undefined) =>
oldData?.map(hook => hook.webhook_url === data.webhook_url ? data : hook) || []
);
},
...options,
});
};
export const useDeleteWebhook = (
options?: UseMutationOptions<
{ message: string },
ApiError,
{ tenantId: string; webhookId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ message: string },
ApiError,
{ tenantId: string; webhookId: string }
>({
mutationFn: ({ tenantId, webhookId }) =>
alertProcessorService.deleteWebhook(tenantId, webhookId),
onSuccess: (_, { tenantId, webhookId }) => {
// Remove the webhook from the list
queryClient.setQueryData(
alertProcessorKeys.webhooks.list(tenantId),
(oldData: WebhookConfig[] | undefined) =>
oldData?.filter(hook => hook.webhook_url !== webhookId) || []
);
},
...options,
});
};
export const useTestWebhook = (
options?: UseMutationOptions<
{ success: boolean; message: string },
ApiError,
{ tenantId: string; webhookId: string }
>
) => {
return useMutation<
{ success: boolean; message: string },
ApiError,
{ tenantId: string; webhookId: string }
>({
mutationFn: ({ tenantId, webhookId }) =>
alertProcessorService.testWebhook(tenantId, webhookId),
...options,
});
};
// SSE Hook for Real-time Alert Updates
export const useAlertSSE = (
tenantId: string,
token?: string,
options?: {
onAlert?: (alert: AlertResponse) => void;
onRecommendation?: (recommendation: AlertResponse) => void;
onAlertUpdate?: (update: AlertResponse) => void;
onSystemStatus?: (status: any) => void;
}
) => {
const queryClient = useQueryClient();
return useQuery({
queryKey: ['alert-sse', tenantId],
queryFn: () => {
return new Promise((resolve, reject) => {
try {
const eventSource = alertProcessorService.createSSEConnection(tenantId, token);
eventSource.onopen = () => {
console.log('Alert SSE connected');
};
eventSource.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Invalidate dashboard data on new alerts/updates
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.dashboard(tenantId)
});
// Invalidate alerts list
queryClient.invalidateQueries({
queryKey: alertProcessorKeys.alerts.lists()
});
// Call appropriate callback based on message type
switch (message.type) {
case 'alert':
options?.onAlert?.(message.data);
break;
case 'recommendation':
options?.onRecommendation?.(message.data);
break;
case 'alert_update':
options?.onAlertUpdate?.(message.data);
// Update specific alert in cache
if (message.data.id) {
queryClient.setQueryData(
alertProcessorKeys.alerts.detail(tenantId, message.data.id),
message.data
);
}
break;
case 'system_status':
options?.onSystemStatus?.(message.data);
break;
}
} catch (error) {
console.error('Error parsing SSE message:', error);
}
};
eventSource.onerror = (error) => {
console.error('Alert SSE error:', error);
reject(error);
};
// Return cleanup function
return () => {
eventSource.close();
};
} catch (error) {
reject(error);
}
});
},
enabled: !!tenantId,
refetchOnWindowFocus: false,
retry: false,
staleTime: Infinity,
});
};
// Utility Hooks
export const useActiveAlertsCount = (tenantId: string) => {
const { data: dashboardData } = useAlertDashboardData(tenantId);
return dashboardData?.active_alerts?.length || 0;
};
export const useAlertsByPriority = (tenantId: string) => {
const { data: dashboardData } = useAlertDashboardData(tenantId);
return dashboardData?.severity_counts || {};
};
export const useUnreadAlertsCount = (tenantId: string) => {
const { data: alerts } = useAlerts(tenantId, {
status: 'active',
limit: 1000 // Get all active alerts to count unread ones
});
return alerts?.data?.filter(alert => alert.status === 'active')?.length || 0;
};

View File

@@ -1,242 +0,0 @@
/**
* Dashboard React Query hooks
* Aggregates data from multiple services for dashboard metrics
*/
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useSalesAnalytics } from './sales';
import { useOrdersDashboard } from './orders';
import { inventoryService } from '../services/inventory';
import { getAlertAnalytics } from '../services/alert_analytics';
import { getSustainabilityWidgetData } from '../services/sustainability';
import { ApiError } from '../client/apiClient';
import type { InventoryDashboardSummary } from '../types/inventory';
import type { AlertAnalytics } from '../services/alert_analytics';
import type { SalesAnalytics } from '../types/sales';
import type { OrdersDashboardSummary } from '../types/orders';
import type { SustainabilityWidgetData } from '../types/sustainability';
export interface DashboardStats {
// Alert metrics
activeAlerts: number;
criticalAlerts: number;
// Order metrics
pendingOrders: number;
ordersToday: number;
ordersTrend: number; // percentage change
// Sales metrics
salesToday: number;
salesTrend: number; // percentage change
salesCurrency: string;
// Inventory metrics
criticalStock: number;
lowStockCount: number;
outOfStockCount: number;
expiringSoon: number;
// Production metrics
productsSoldToday: number;
productsSoldTrend: number;
// Sustainability metrics
wasteReductionPercentage?: number;
monthlySavingsEur?: number;
// Data freshness
lastUpdated: string;
}
interface AggregatedDashboardData {
alerts?: AlertAnalytics;
orders?: OrdersDashboardSummary;
sales?: SalesAnalytics;
inventory?: InventoryDashboardSummary;
sustainability?: SustainabilityWidgetData;
}
// Query Keys
export const dashboardKeys = {
all: ['dashboard'] as const,
stats: (tenantId: string) => [...dashboardKeys.all, 'stats', tenantId] as const,
inventory: (tenantId: string) => [...dashboardKeys.all, 'inventory', tenantId] as const,
} as const;
/**
* Fetch inventory dashboard summary
*/
export const useInventoryDashboard = (
tenantId: string,
options?: Omit<UseQueryOptions<InventoryDashboardSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<InventoryDashboardSummary, ApiError>({
queryKey: dashboardKeys.inventory(tenantId),
queryFn: () => inventoryService.getDashboardSummary(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Fetch alert analytics
*/
export const useAlertAnalytics = (
tenantId: string,
days: number = 7,
options?: Omit<UseQueryOptions<AlertAnalytics, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<AlertAnalytics, ApiError>({
queryKey: ['alerts', 'analytics', tenantId, days],
queryFn: () => getAlertAnalytics(tenantId, days),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Calculate percentage change between two values
*/
function calculateTrend(current: number, previous: number): number {
if (previous === 0) return current > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100);
}
/**
* Calculate today's sales from sales records (REMOVED - Professional/Enterprise tier feature)
* Basic tier users don't get sales analytics on dashboard
*/
function calculateTodaySales(): { amount: number; trend: number; productsSold: number; productsTrend: number } {
// Return zero values - sales analytics not available for basic tier
return { amount: 0, trend: 0, productsSold: 0, productsTrend: 0 };
}
/**
* Calculate orders metrics
*/
function calculateOrdersMetrics(ordersData?: OrdersDashboardSummary): { pending: number; today: number; trend: number } {
if (!ordersData) {
return { pending: 0, today: 0, trend: 0 };
}
const pendingCount = ordersData.pending_orders || 0;
const todayCount = ordersData.total_orders_today || 0;
return {
pending: pendingCount,
today: todayCount,
trend: 0, // Trend calculation removed - needs historical data
};
}
/**
* Aggregate dashboard data from all services
* NOTE: Sales analytics removed - Professional/Enterprise tier feature
*/
function aggregateDashboardStats(data: AggregatedDashboardData): DashboardStats {
const sales = calculateTodaySales(); // Returns zeros for basic tier
const orders = calculateOrdersMetrics(data.orders);
const criticalStockCount =
(data.inventory?.low_stock_items || 0) +
(data.inventory?.out_of_stock_items || 0);
return {
// Alerts
activeAlerts: data.alerts?.activeAlerts || 0,
criticalAlerts: data.alerts?.totalAlerts || 0,
// Orders
pendingOrders: orders.pending,
ordersToday: orders.today,
ordersTrend: orders.trend,
// Sales (REMOVED - not available for basic tier)
salesToday: 0,
salesTrend: 0,
salesCurrency: '€',
// Inventory
criticalStock: criticalStockCount,
lowStockCount: data.inventory?.low_stock_items || 0,
outOfStockCount: data.inventory?.out_of_stock_items || 0,
expiringSoon: data.inventory?.expiring_soon_items || 0,
// Products (REMOVED - not available for basic tier)
productsSoldToday: 0,
productsSoldTrend: 0,
// Sustainability
wasteReductionPercentage: data.sustainability?.waste_reduction_percentage,
monthlySavingsEur: data.sustainability?.financial_savings_eur,
// Metadata
lastUpdated: new Date().toISOString(),
};
}
/**
* Main hook to fetch aggregated dashboard statistics
* Combines data from multiple services into a single cohesive dashboard view
*/
export const useDashboardStats = (
tenantId: string,
options?: Omit<UseQueryOptions<DashboardStats, ApiError>, 'queryKey' | 'queryFn'>
) => {
// Get today's date range for sales
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
return useQuery<DashboardStats, ApiError>({
queryKey: dashboardKeys.stats(tenantId),
queryFn: async () => {
// Fetch all data in parallel (REMOVED sales analytics - Professional/Enterprise tier only)
const [alertsData, ordersData, inventoryData, sustainabilityData] = await Promise.allSettled([
getAlertAnalytics(tenantId, 7),
// Note: OrdersService methods are static
import('../services/orders').then(({ OrdersService }) =>
OrdersService.getDashboardSummary(tenantId)
),
inventoryService.getDashboardSummary(tenantId),
getSustainabilityWidgetData(tenantId, 30), // 30 days for monthly savings
]);
// Extract data or use undefined for failed requests
const aggregatedData: AggregatedDashboardData = {
alerts: alertsData.status === 'fulfilled' ? alertsData.value : undefined,
orders: ordersData.status === 'fulfilled' ? ordersData.value : undefined,
sales: undefined, // REMOVED - Professional/Enterprise tier only
inventory: inventoryData.status === 'fulfilled' ? inventoryData.value : undefined,
sustainability: sustainabilityData.status === 'fulfilled' ? sustainabilityData.value : undefined,
};
// Log any failures for debugging
if (alertsData.status === 'rejected') {
console.warn('[Dashboard] Failed to fetch alerts:', alertsData.reason);
}
if (ordersData.status === 'rejected') {
console.warn('[Dashboard] Failed to fetch orders:', ordersData.reason);
}
if (inventoryData.status === 'rejected') {
console.warn('[Dashboard] Failed to fetch inventory:', inventoryData.reason);
}
if (sustainabilityData.status === 'rejected') {
console.warn('[Dashboard] Failed to fetch sustainability:', sustainabilityData.reason);
}
return aggregateDashboardStats(aggregatedData);
},
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // Auto-refresh every minute
retry: 2, // Retry failed requests twice
retryDelay: 1000, // Wait 1s between retries
...options,
});
};

View File

@@ -1,50 +1,48 @@
/**
* Enterprise Dashboard Hooks - Clean Implementation
*
* Phase 3 Complete: All dashboard hooks call services directly.
* Distribution and forecast still use orchestrator (Phase 4 migration).
*/
export {
useNetworkSummary,
useChildrenPerformance,
useChildTenants,
useChildSales,
useChildInventory,
useChildProduction,
} from './useEnterpriseDashboard';
export type {
NetworkSummary,
PerformanceRankings as ChildPerformance,
ChildTenant,
SalesSummary,
InventorySummary,
ProductionSummary,
} from './useEnterpriseDashboard';
// Distribution and forecast hooks (Phase 4 - To be migrated)
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { enterpriseService, NetworkSummary, ChildPerformance, DistributionOverview, ForecastSummary, NetworkPerformance } from '../services/enterprise';
import { ApiError } from '../client';
import { ApiError, apiClient } from '../client';
// Query Keys
export const enterpriseKeys = {
all: ['enterprise'] as const,
networkSummary: (tenantId: string) => [...enterpriseKeys.all, 'network-summary', tenantId] as const,
childrenPerformance: (tenantId: string, metric: string, period: number) =>
[...enterpriseKeys.all, 'children-performance', tenantId, metric, period] as const,
distributionOverview: (tenantId: string, date?: string) =>
[...enterpriseKeys.all, 'distribution-overview', tenantId, date] as const,
forecastSummary: (tenantId: string, days: number) =>
[...enterpriseKeys.all, 'forecast-summary', tenantId, days] as const,
networkPerformance: (tenantId: string, startDate?: string, endDate?: string) =>
[...enterpriseKeys.all, 'network-performance', tenantId, startDate, endDate] as const,
} as const;
export interface DistributionOverview {
route_sequences: any[];
status_counts: {
pending: number;
in_transit: number;
delivered: number;
failed: number;
[key: string]: number;
};
}
// Hooks
export const useNetworkSummary = (
tenantId: string,
options?: Omit<UseQueryOptions<NetworkSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<NetworkSummary, ApiError>({
queryKey: enterpriseKeys.networkSummary(tenantId),
queryFn: () => enterpriseService.getNetworkSummary(tenantId),
enabled: !!tenantId,
staleTime: 30000, // 30 seconds
...options,
});
};
export const useChildrenPerformance = (
tenantId: string,
metric: string,
period: number,
options?: Omit<UseQueryOptions<ChildPerformance, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ChildPerformance, ApiError>({
queryKey: enterpriseKeys.childrenPerformance(tenantId, metric, period),
queryFn: () => enterpriseService.getChildrenPerformance(tenantId, metric, period),
enabled: !!tenantId,
staleTime: 60000, // 1 minute
...options,
});
};
export interface ForecastSummary {
aggregated_forecasts: Record<string, any>;
days_forecast: number;
last_updated: string;
}
export const useDistributionOverview = (
tenantId: string,
@@ -52,10 +50,17 @@ export const useDistributionOverview = (
options?: Omit<UseQueryOptions<DistributionOverview, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<DistributionOverview, ApiError>({
queryKey: enterpriseKeys.distributionOverview(tenantId, targetDate),
queryFn: () => enterpriseService.getDistributionOverview(tenantId, targetDate),
queryKey: ['enterprise', 'distribution-overview', tenantId, targetDate],
queryFn: async () => {
const params = new URLSearchParams();
if (targetDate) params.append('target_date', targetDate);
const queryString = params.toString();
return apiClient.get<DistributionOverview>(
`/tenants/${tenantId}/enterprise/distribution-overview${queryString ? `?${queryString}` : ''}`
);
},
enabled: !!tenantId,
staleTime: 30000, // 30 seconds
staleTime: 30000,
...options,
});
};
@@ -66,24 +71,14 @@ export const useForecastSummary = (
options?: Omit<UseQueryOptions<ForecastSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ForecastSummary, ApiError>({
queryKey: enterpriseKeys.forecastSummary(tenantId, daysAhead),
queryFn: () => enterpriseService.getForecastSummary(tenantId, daysAhead),
enabled: !!tenantId,
staleTime: 120000, // 2 minutes
...options,
});
};
export const useNetworkPerformance = (
tenantId: string,
startDate?: string,
endDate?: string,
options?: Omit<UseQueryOptions<NetworkPerformance, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<NetworkPerformance, ApiError>({
queryKey: enterpriseKeys.networkPerformance(tenantId, startDate, endDate),
queryFn: () => enterpriseService.getNetworkPerformance(tenantId, startDate, endDate),
queryKey: ['enterprise', 'forecast-summary', tenantId, daysAhead],
queryFn: async () => {
return apiClient.get<ForecastSummary>(
`/tenants/${tenantId}/enterprise/forecast-summary?days_ahead=${daysAhead}`
);
},
enabled: !!tenantId,
staleTime: 120000,
...options,
});
};

View File

@@ -1,481 +0,0 @@
// ================================================================
// frontend/src/api/hooks/newDashboard.ts
// ================================================================
/**
* API Hooks for JTBD-Aligned Dashboard
*
* Provides data fetching for the redesigned bakery dashboard with focus on:
* - Health status
* - Action queue
* - Orchestration summary
* - Production timeline
* - Key insights
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
// ============================================================
// Types
// ============================================================
export interface HealthChecklistItem {
icon: 'check' | 'warning' | 'alert' | 'ai_handled';
text?: string; // Deprecated: Use textKey instead
textKey?: string; // i18n key for translation
textParams?: Record<string, any>; // Parameters for i18n translation
actionRequired: boolean;
status: 'good' | 'ai_handled' | 'needs_you'; // Tri-state status
actionPath?: string; // Optional path to navigate for action
}
export interface HeadlineData {
key: string;
params: Record<string, any>;
}
export interface BakeryHealthStatus {
status: 'green' | 'yellow' | 'red';
headline: string | HeadlineData; // Can be string (deprecated) or i18n object
lastOrchestrationRun: string | null;
nextScheduledRun: string;
checklistItems: HealthChecklistItem[];
criticalIssues: number;
pendingActions: number;
aiPreventedIssues: number; // Count of issues AI prevented
}
export interface ReasoningInputs {
customerOrders: number;
historicalDemand: boolean;
inventoryLevels: boolean;
aiInsights: boolean;
}
// Note: This is a different interface from PurchaseOrderSummary in purchase_orders.ts
// This is specifically for OrchestrationSummary dashboard display
export interface PurchaseOrderSummary {
supplierName: string;
itemCategories: string[];
totalAmount: number;
}
export interface ProductionBatchSummary {
productName: string;
quantity: number;
readyByTime: string;
}
export interface OrchestrationSummary {
runTimestamp: string | null;
runNumber: number | null;
status: string;
purchaseOrdersCreated: number;
purchaseOrdersSummary: PurchaseOrderSummary[];
productionBatchesCreated: number;
productionBatchesSummary: ProductionBatchSummary[];
reasoningInputs: ReasoningInputs;
userActionsRequired: number;
durationSeconds: number | null;
aiAssisted: boolean;
message_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for message
}
export interface ActionButton {
label_i18n: {
key: string;
params?: Record<string, any>;
}; // i18n data for button label
type: 'primary' | 'secondary' | 'tertiary';
action: string;
}
export interface ActionItem {
id: string;
type: string;
urgency: 'critical' | 'important' | 'normal';
title?: string; // Legacy field kept for alerts
title_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for title
subtitle?: string; // Legacy field kept for alerts
subtitle_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for subtitle
reasoning?: string; // Legacy field kept for alerts
reasoning_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for reasoning
consequence_i18n: {
key: string;
params?: Record<string, any>;
}; // i18n data for consequence
reasoning_data?: any; // Structured reasoning data for i18n translation
amount?: number;
currency?: string;
actions: ActionButton[];
estimatedTimeMinutes: number;
}
export interface ActionQueue {
actions: ActionItem[];
totalActions: number;
criticalCount: number;
importantCount: number;
}
// New unified action queue with time-based grouping
export interface EnrichedAlert {
id: string;
alert_type: string;
type_class: string;
priority_level: string;
priority_score: number;
title: string;
message: string;
actions: Array<{
type: string;
label: string;
variant: 'primary' | 'secondary' | 'ghost';
metadata?: Record<string, any>;
disabled?: boolean;
estimated_time_minutes?: number;
}>;
urgency_context?: {
deadline?: string;
time_until_consequence_hours?: number;
};
alert_metadata?: {
escalation?: {
original_score: number;
boost_applied: number;
escalated_at: string;
reason: string;
};
};
business_impact?: {
financial_impact_eur?: number;
affected_orders?: number;
};
ai_reasoning_summary?: string;
hidden_from_ui?: boolean;
}
export interface UnifiedActionQueue {
urgent: EnrichedAlert[]; // <6h to deadline or CRITICAL
today: EnrichedAlert[]; // <24h to deadline
week: EnrichedAlert[]; // <7d to deadline or escalated
totalActions: number;
urgentCount: number;
todayCount: number;
weekCount: number;
}
export interface ProductionTimelineItem {
id: string;
batchNumber: string;
productName: string;
quantity: number;
unit: string;
plannedStartTime: string | null;
plannedEndTime: string | null;
actualStartTime: string | null;
status: string;
statusIcon: string;
statusText: string;
progress: number;
readyBy: string | null;
priority: string;
reasoning?: string; // Deprecated: Use reasoning_data instead
reasoning_data?: any; // Structured reasoning data for i18n translation
reasoning_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for reasoning
status_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for status text
}
export interface ProductionTimeline {
timeline: ProductionTimelineItem[];
totalBatches: number;
completedBatches: number;
inProgressBatches: number;
pendingBatches: number;
}
export interface InsightCard {
color: 'green' | 'amber' | 'red';
i18n: {
label: {
key: string;
params?: Record<string, any>;
};
value: {
key: string;
params?: Record<string, any>;
};
detail: {
key: string;
params?: Record<string, any>;
} | null;
};
}
export interface Insights {
savings: InsightCard;
inventory: InsightCard;
waste: InsightCard;
deliveries: InsightCard;
}
// ============================================================
// Hooks
// ============================================================
/**
* Get bakery health status
*
* This is the top-level health indicator showing if the bakery is running smoothly.
* Updates every 30 seconds to keep status fresh.
*/
export function useBakeryHealthStatus(tenantId: string) {
return useQuery<BakeryHealthStatus>({
queryKey: ['bakery-health-status', tenantId],
queryFn: async () => {
return await apiClient.get(
`/tenants/${tenantId}/dashboard/health-status`
);
},
enabled: !!tenantId, // Only fetch when tenantId is available
refetchInterval: 30000, // Refresh every 30 seconds
staleTime: 20000, // Consider stale after 20 seconds
retry: 2,
});
}
/**
* Get orchestration summary
*
* Shows what the automated system did (transparency for trust building).
*/
export function useOrchestrationSummary(tenantId: string, runId?: string) {
return useQuery<OrchestrationSummary>({
queryKey: ['orchestration-summary', tenantId, runId],
queryFn: async () => {
const params = runId ? { run_id: runId } : {};
return await apiClient.get(
`/tenants/${tenantId}/dashboard/orchestration-summary`,
{ params }
);
},
enabled: !!tenantId, // Only fetch when tenantId is available
staleTime: 60000, // Summary doesn't change often
retry: 2,
});
}
/**
* Get action queue (LEGACY - use useUnifiedActionQueue for new implementation)
*
* Prioritized list of what requires user attention right now.
* This is the core JTBD dashboard feature.
*/
export function useActionQueue(tenantId: string) {
return useQuery<ActionQueue>({
queryKey: ['action-queue', tenantId],
queryFn: async () => {
return await apiClient.get(
`/tenants/${tenantId}/dashboard/action-queue`
);
},
enabled: !!tenantId, // Only fetch when tenantId is available
refetchInterval: 60000, // Refresh every minute
staleTime: 30000,
retry: 2,
});
}
/**
* Get unified action queue with time-based grouping
*
* Returns all action-needed alerts grouped by urgency:
* - URGENT: <6h to deadline or CRITICAL priority
* - TODAY: <24h to deadline
* - THIS WEEK: <7d to deadline or escalated (>48h pending)
*
* This is the NEW implementation for the redesigned Action Queue Card.
*/
export function useUnifiedActionQueue(tenantId: string) {
return useQuery<UnifiedActionQueue>({
queryKey: ['unified-action-queue', tenantId],
queryFn: async () => {
return await apiClient.get(
`/tenants/${tenantId}/dashboard/unified-action-queue`
);
},
enabled: !!tenantId,
refetchInterval: 30000, // Refresh every 30 seconds (more frequent than legacy)
staleTime: 15000,
retry: 2,
});
}
/**
* Get production timeline
*
* Shows today's production schedule in chronological order.
*/
export function useProductionTimeline(tenantId: string) {
return useQuery<ProductionTimeline>({
queryKey: ['production-timeline', tenantId],
queryFn: async () => {
return await apiClient.get(
`/tenants/${tenantId}/dashboard/production-timeline`
);
},
enabled: !!tenantId, // Only fetch when tenantId is available
refetchInterval: 60000, // Refresh every minute
staleTime: 30000,
retry: 2,
});
}
/**
* Get key insights
*
* Glanceable metrics on savings, inventory, waste, and deliveries.
*/
export function useInsights(tenantId: string) {
return useQuery<Insights>({
queryKey: ['dashboard-insights', tenantId],
queryFn: async () => {
return await apiClient.get(
`/tenants/${tenantId}/dashboard/insights`
);
},
enabled: !!tenantId, // Only fetch when tenantId is available
refetchInterval: 120000, // Refresh every 2 minutes
staleTime: 60000,
retry: 2,
});
}
/**
* Get execution progress - plan vs actual for today
*
* Shows how today's execution is progressing:
* - Production: batches completed/in-progress/pending
* - Deliveries: received/pending/overdue
* - Approvals: pending count
*
* This is the NEW implementation for the ExecutionProgressTracker component.
*/
export function useExecutionProgress(tenantId: string) {
return useQuery({
queryKey: ['execution-progress', tenantId],
queryFn: async () => {
return await apiClient.get(
`/tenants/${tenantId}/dashboard/execution-progress`
);
},
enabled: !!tenantId,
refetchInterval: 60000, // Refresh every minute
staleTime: 30000,
retry: 2,
});
}
// ============================================================
// Action Mutations
// ============================================================
/**
* Approve a purchase order from the action queue
*/
export function useApprovePurchaseOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, poId }: { tenantId: string; poId: string }) => {
const response = await apiClient.post(
`/procurement/tenants/${tenantId}/purchase-orders/${poId}/approve`
);
return response.data;
},
onSuccess: (_, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: ['action-queue', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['orchestration-summary', variables.tenantId] });
},
});
}
/**
* Dismiss an alert from the action queue
*/
export function useDismissAlert() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, alertId }: { tenantId: string; alertId: string }) => {
const response = await apiClient.post(
`/alert-processor/tenants/${tenantId}/alerts/${alertId}/dismiss`
);
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['action-queue', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
},
});
}
/**
* Start a production batch
*/
export function useStartProductionBatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, batchId }: { tenantId: string; batchId: string }) => {
const response = await apiClient.post(
`/production/tenants/${tenantId}/production-batches/${batchId}/start`
);
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['production-timeline', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
},
});
}
/**
* Pause a production batch
*/
export function usePauseProductionBatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tenantId, batchId }: { tenantId: string; batchId: string }) => {
const response = await apiClient.post(
`/production/tenants/${tenantId}/production-batches/${batchId}/pause`
);
return response.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['production-timeline', variables.tenantId] });
queryClient.invalidateQueries({ queryKey: ['bakery-health-status', variables.tenantId] });
},
});
}

View File

@@ -1,18 +1,83 @@
/**
* Orchestrator React Query hooks
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as orchestratorService from '../services/orchestrator';
import { ApiError } from '../client';
import {
OrchestratorConfig,
OrchestratorStatus,
OrchestratorWorkflowResponse,
WorkflowExecutionDetail,
WorkflowExecutionSummary
} from '../types/orchestrator';
// ============================================================================
// QUERIES
// ============================================================================
export const useOrchestratorStatus = (tenantId: string) => {
return useQuery<OrchestratorStatus>({
queryKey: ['orchestrator', 'status', tenantId],
queryFn: () => orchestratorService.getOrchestratorStatus(tenantId),
enabled: !!tenantId,
refetchInterval: 30000, // Refresh every 30s
});
};
export const useOrchestratorConfig = (tenantId: string) => {
return useQuery<OrchestratorConfig>({
queryKey: ['orchestrator', 'config', tenantId],
queryFn: () => orchestratorService.getOrchestratorConfig(tenantId),
enabled: !!tenantId,
});
};
export const useLatestWorkflowExecution = (tenantId: string) => {
return useQuery<WorkflowExecutionDetail | null>({
queryKey: ['orchestrator', 'executions', 'latest', tenantId],
queryFn: () => orchestratorService.getLatestWorkflowExecution(tenantId),
enabled: !!tenantId,
refetchInterval: (data) => {
// If running, poll more frequently
return data?.status === 'running' ? 5000 : 60000;
},
});
};
export const useWorkflowExecutions = (
tenantId: string,
params?: { limit?: number; offset?: number; status?: string }
) => {
return useQuery<WorkflowExecutionSummary[]>({
queryKey: ['orchestrator', 'executions', tenantId, params],
queryFn: () => orchestratorService.listWorkflowExecutions(tenantId, params),
enabled: !!tenantId,
});
};
export const useWorkflowExecution = (tenantId: string, executionId: string) => {
return useQuery<WorkflowExecutionDetail>({
queryKey: ['orchestrator', 'execution', tenantId, executionId],
queryFn: () => orchestratorService.getWorkflowExecution(tenantId, executionId),
enabled: !!tenantId && !!executionId,
refetchInterval: (data) => {
// If running, poll more frequently
return data?.status === 'running' ? 3000 : false;
},
});
};
// ============================================================================
// MUTATIONS
// ============================================================================
// Mutations
export const useRunDailyWorkflow = (
options?: Parameters<typeof useMutation>[0]
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
mutationFn: (tenantId: string) =>
orchestratorService.runDailyWorkflow(tenantId),
onSuccess: (_, tenantId) => {
// Invalidate queries to refresh dashboard data after workflow execution
@@ -22,7 +87,72 @@ export const useRunDailyWorkflow = (
// Also invalidate dashboard queries to refresh stats
queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
// Invalidate orchestrator queries
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions'] });
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'status'] });
},
...options,
});
});
};
export const useTestWorkflow = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
orchestratorService.testWorkflow(tenantId),
onSuccess: (_, tenantId) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions'] });
},
...options,
});
};
export const useUpdateOrchestratorConfig = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, config }: { tenantId: string; config: Partial<OrchestratorConfig> }) =>
orchestratorService.updateOrchestratorConfig(tenantId, config),
onSuccess: (_, { tenantId }) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'config', tenantId] });
},
...options,
});
};
export const useCancelWorkflowExecution = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, executionId }: { tenantId: string; executionId: string }) =>
orchestratorService.cancelWorkflowExecution(tenantId, executionId),
onSuccess: (_, { tenantId, executionId }) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'execution', tenantId, executionId] });
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions', tenantId] });
},
...options,
});
};
export const useRetryWorkflowExecution = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, executionId }: { tenantId: string; executionId: string }) =>
orchestratorService.retryWorkflowExecution(tenantId, executionId),
onSuccess: (_, { tenantId, executionId }) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'execution', tenantId, executionId] });
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions', tenantId] });
},
...options,
});
};

View File

@@ -17,8 +17,10 @@ import {
ProcurementPerformance,
TimePeriod,
} from '../types/performance';
import { useProductionDashboard } from './production';
import { useInventoryDashboard } from './dashboard';
import { useProductionDashboard, useActiveBatches } from './production';
import { inventoryService } from '../services/inventory';
import { useQuery } from '@tanstack/react-query';
import type { InventoryDashboardSummary } from '../types/inventory';
import { useSalesAnalytics } from './sales';
import { useProcurementDashboard } from './procurement';
import { useOrdersDashboard } from './orders';
@@ -55,6 +57,44 @@ const getDateRangeForPeriod = (period: TimePeriod): { startDate: string; endDate
};
};
const getPreviousPeriodDates = (period: TimePeriod): { startDate: string; endDate: string } => {
const endDate = new Date();
const startDate = new Date();
switch (period) {
case 'day':
// Previous day: 2 days ago to 1 day ago
startDate.setDate(endDate.getDate() - 2);
endDate.setDate(endDate.getDate() - 1);
break;
case 'week':
// Previous week: 14 days ago to 7 days ago
startDate.setDate(endDate.getDate() - 14);
endDate.setDate(endDate.getDate() - 7);
break;
case 'month':
// Previous month: 2 months ago to 1 month ago
startDate.setMonth(endDate.getMonth() - 2);
endDate.setMonth(endDate.getMonth() - 1);
break;
case 'quarter':
// Previous quarter: 6 months ago to 3 months ago
startDate.setMonth(endDate.getMonth() - 6);
endDate.setMonth(endDate.getMonth() - 3);
break;
case 'year':
// Previous year: 2 years ago to 1 year ago
startDate.setFullYear(endDate.getFullYear() - 2);
endDate.setFullYear(endDate.getFullYear() - 1);
break;
}
return {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
};
};
const calculateTrend = (current: number, previous: number): 'up' | 'down' | 'stable' => {
const change = ((current - previous) / previous) * 100;
if (Math.abs(change) < 1) return 'stable';
@@ -107,7 +147,12 @@ export const useProductionPerformance = (tenantId: string, period: TimePeriod =
// ============================================================================
export const useInventoryPerformance = (tenantId: string) => {
const { data: dashboard, isLoading: dashboardLoading } = useInventoryDashboard(tenantId);
const { data: dashboard, isLoading: dashboardLoading } = useQuery<InventoryDashboardSummary>({
queryKey: ['inventory-dashboard', tenantId],
queryFn: () => inventoryService.getDashboardSummary(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
});
// Extract primitive values to prevent unnecessary recalculations
const totalItems = dashboard?.total_ingredients || 1;
@@ -120,16 +165,33 @@ export const useInventoryPerformance = (tenantId: string) => {
const performance: InventoryPerformance | undefined = useMemo(() => {
if (!dashboard) return undefined;
// Calculate inventory turnover rate estimate
// Formula: (Cost of Goods Sold / Average Inventory) * 100
// Since we don't have COGS, estimate using stock movements as proxy
// Healthy range: 4-6 times per year (monthly turnover: 0.33-0.5)
const recentMovements = dashboard.recent_movements || 0;
const currentStockValue = stockValue || 1; // Avoid division by zero
const turnoverRate = recentMovements > 0
? ((recentMovements / currentStockValue) * 100)
: 0;
// Calculate waste rate from stock movements
// Formula: (Waste Quantity / Total Inventory) * 100
// Typical food waste rate: 2-10% depending on product category
const recentWaste = dashboard.recent_waste || 0;
const totalStock = dashboard.total_stock_items || 1; // Avoid division by zero
const wasteRate = (recentWaste / totalStock) * 100;
return {
stock_accuracy: 100 - ((lowStockCount + outOfStockCount) / totalItems * 100),
turnover_rate: 0, // TODO: Not available in dashboard
waste_rate: 0, // TODO: Derive from stock movements if available
turnover_rate: Math.min(100, Math.max(0, turnoverRate)), // Cap at 0-100%
waste_rate: Math.min(100, Math.max(0, wasteRate)), // Cap at 0-100%
low_stock_count: lowStockCount,
compliance_rate: foodSafetyAlertsActive === 0 ? 100 : 90, // Simplified compliance
expiring_items_count: expiringItems,
stock_value: stockValue,
};
}, [totalItems, lowStockCount, outOfStockCount, foodSafetyAlertsActive, expiringItems, stockValue]);
}, [dashboard, totalItems, lowStockCount, outOfStockCount, foodSafetyAlertsActive, expiringItems, stockValue]);
return {
data: performance,
@@ -144,29 +206,57 @@ export const useInventoryPerformance = (tenantId: string) => {
export const useSalesPerformance = (tenantId: string, period: TimePeriod = 'week') => {
const { startDate, endDate } = getDateRangeForPeriod(period);
// Get current period data
const { data: salesData, isLoading: salesLoading } = useSalesAnalytics(
tenantId,
startDate,
endDate
);
// Get previous period data for growth rate calculation
const previousPeriod = getPreviousPeriodDates(period);
const { data: previousSalesData } = useSalesAnalytics(
tenantId,
previousPeriod.startDate,
previousPeriod.endDate
);
// Extract primitive values to prevent unnecessary recalculations
const totalRevenue = salesData?.total_revenue || 0;
const totalTransactions = salesData?.total_transactions || 0;
const avgTransactionValue = salesData?.average_transaction_value || 0;
const topProductsString = salesData?.top_products ? JSON.stringify(salesData.top_products) : '[]';
const previousRevenue = previousSalesData?.total_revenue || 0;
const performance: SalesPerformance | undefined = useMemo(() => {
if (!salesData) return undefined;
const topProducts = JSON.parse(topProductsString);
// Calculate growth rate: ((current - previous) / previous) * 100
let growthRate = 0;
if (previousRevenue > 0 && totalRevenue > 0) {
growthRate = ((totalRevenue - previousRevenue) / previousRevenue) * 100;
// Cap at ±999% to avoid display issues
growthRate = Math.max(-999, Math.min(999, growthRate));
}
// Parse channel performance from sales_by_channel if available
const channelPerformance = salesData.sales_by_channel
? Object.entries(salesData.sales_by_channel).map(([channel, data]: [string, any]) => ({
channel,
revenue: data.revenue || 0,
transactions: data.transactions || 0,
growth: data.growth || 0,
}))
: [];
return {
total_revenue: totalRevenue,
total_transactions: totalTransactions,
average_transaction_value: avgTransactionValue,
growth_rate: 0, // TODO: Calculate from trends
channel_performance: [], // TODO: Parse from sales_by_channel if needed
growth_rate: growthRate,
channel_performance: channelPerformance,
top_products: Array.isArray(topProducts)
? topProducts.map((product: any) => ({
product_id: product.inventory_product_id || '',
@@ -176,7 +266,7 @@ export const useSalesPerformance = (tenantId: string, period: TimePeriod = 'week
}))
: [],
};
}, [totalRevenue, totalTransactions, avgTransactionValue, topProductsString]);
}, [totalRevenue, totalTransactions, avgTransactionValue, topProductsString, previousRevenue]);
return {
data: performance,
@@ -411,14 +501,20 @@ export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') =>
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
// Get previous period data for trend calculation
const previousPeriod = getPreviousPeriodDates(period);
const { data: previousProduction } = useProductionPerformance(tenantId, period);
const { data: previousInventory } = useInventoryPerformance(tenantId);
const { data: previousProcurement } = useProcurementPerformance(tenantId);
const kpis: KPIMetric[] | undefined = useMemo(() => {
if (!production || !inventory || !procurement) return undefined;
// TODO: Get previous period data for accurate trends
const previousProduction = production.efficiency * 0.95; // Mock previous value
const previousInventory = inventory.stock_accuracy * 0.98;
const previousProcurement = procurement.on_time_delivery_rate * 0.97;
const previousQuality = production.quality_rate * 0.96;
// Calculate trends using previous period data if available, otherwise estimate
const prevProductionEfficiency = previousProduction?.efficiency || production.efficiency * 0.95;
const prevInventoryAccuracy = previousInventory?.stock_accuracy || inventory.stock_accuracy * 0.98;
const prevProcurementOnTime = previousProcurement?.on_time_delivery_rate || procurement.on_time_delivery_rate * 0.97;
const prevProductionQuality = previousProduction?.quality_rate || production.quality_rate * 0.96;
return [
{
@@ -426,9 +522,9 @@ export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') =>
name: 'Eficiencia General',
current_value: production.efficiency,
target_value: 90,
previous_value: previousProduction,
previous_value: prevProductionEfficiency,
unit: '%',
trend: calculateTrend(production.efficiency, previousProduction),
trend: calculateTrend(production.efficiency, prevProductionEfficiency),
status: calculateStatus(production.efficiency, 90),
},
{
@@ -436,9 +532,9 @@ export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') =>
name: 'Tasa de Calidad',
current_value: production.quality_rate,
target_value: 95,
previous_value: previousQuality,
previous_value: prevProductionQuality,
unit: '%',
trend: calculateTrend(production.quality_rate, previousQuality),
trend: calculateTrend(production.quality_rate, prevProductionQuality),
status: calculateStatus(production.quality_rate, 95),
},
{
@@ -446,9 +542,9 @@ export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') =>
name: 'Entrega a Tiempo',
current_value: procurement.on_time_delivery_rate,
target_value: 95,
previous_value: previousProcurement,
previous_value: prevProcurementOnTime,
unit: '%',
trend: calculateTrend(procurement.on_time_delivery_rate, previousProcurement),
trend: calculateTrend(procurement.on_time_delivery_rate, prevProcurementOnTime),
status: calculateStatus(procurement.on_time_delivery_rate, 95),
},
{
@@ -456,9 +552,9 @@ export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') =>
name: 'Precisión de Inventario',
current_value: inventory.stock_accuracy,
target_value: 98,
previous_value: previousInventory,
previous_value: prevInventoryAccuracy,
unit: '%',
trend: calculateTrend(inventory.stock_accuracy, previousInventory),
trend: calculateTrend(inventory.stock_accuracy, prevInventoryAccuracy),
status: calculateStatus(inventory.stock_accuracy, 98),
},
];
@@ -475,7 +571,12 @@ export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') =>
// ============================================================================
export const usePerformanceAlerts = (tenantId: string) => {
const { data: inventory, isLoading: inventoryLoading } = useInventoryDashboard(tenantId);
const { data: inventory, isLoading: inventoryLoading } = useQuery<InventoryDashboardSummary>({
queryKey: ['inventory-dashboard', tenantId],
queryFn: () => inventoryService.getDashboardSummary(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
});
const { data: procurement, isLoading: procurementLoading } = useProcurementDashboard(tenantId);
// Extract primitive values to prevent unnecessary recalculations
@@ -576,16 +677,90 @@ export const usePerformanceAlerts = (tenantId: string) => {
// ============================================================================
export const useHourlyProductivity = (tenantId: string) => {
// TODO: This requires time-series data aggregation from production batches
// For now, returning empty until backend provides hourly aggregation endpoint
// Aggregate production batch data by hour for productivity tracking
const { data: activeBatches } = useActiveBatches(tenantId);
const { data: salesData } = useSalesPerformance(tenantId, 'day');
return useQuery<HourlyProductivity[]>({
queryKey: ['performance', 'hourly', tenantId],
queryKey: ['performance', 'hourly', tenantId, activeBatches, salesData],
queryFn: async () => {
// Placeholder - backend endpoint needed for real hourly data
return [];
if (!activeBatches?.batches) return [];
// Create hourly buckets for the last 24 hours
const now = new Date();
const hourlyMap = new Map<string, {
production_count: number;
completed_batches: number;
total_batches: number;
total_planned_quantity: number;
total_actual_quantity: number;
}>();
// Initialize buckets for last 24 hours
for (let i = 23; i >= 0; i--) {
const hourDate = new Date(now);
hourDate.setHours(now.getHours() - i, 0, 0, 0);
const hourKey = hourDate.toISOString().substring(0, 13); // YYYY-MM-DDTHH
hourlyMap.set(hourKey, {
production_count: 0,
completed_batches: 0,
total_batches: 0,
total_planned_quantity: 0,
total_actual_quantity: 0,
});
}
// Aggregate batch data by hour
activeBatches.batches.forEach((batch) => {
// Use actual_start_time if available, otherwise planned_start_time
const batchTime = batch.actual_start_time || batch.planned_start_time;
if (!batchTime) return;
const batchDate = new Date(batchTime);
const hourKey = batchDate.toISOString().substring(0, 13);
const bucket = hourlyMap.get(hourKey);
if (!bucket) return; // Outside our 24-hour window
bucket.total_batches += 1;
bucket.total_planned_quantity += batch.planned_quantity || 0;
if (batch.status === 'COMPLETED') {
bucket.completed_batches += 1;
bucket.total_actual_quantity += batch.actual_quantity || batch.planned_quantity || 0;
bucket.production_count += batch.actual_quantity || batch.planned_quantity || 0;
} else if (batch.status === 'IN_PROGRESS' || batch.status === 'QUALITY_CHECK') {
// For in-progress, estimate based on time elapsed
const elapsed = now.getTime() - batchDate.getTime();
const duration = (batch.actual_duration_minutes || batch.planned_duration_minutes || 60) * 60 * 1000;
const progress = Math.min(1, elapsed / duration);
bucket.production_count += Math.floor((batch.planned_quantity || 0) * progress);
}
});
// Convert to HourlyProductivity array
const result: HourlyProductivity[] = Array.from(hourlyMap.entries())
.map(([hourKey, data]) => {
// Calculate efficiency: (actual output / planned output) * 100
const efficiency = data.total_planned_quantity > 0
? Math.min(100, (data.total_actual_quantity / data.total_planned_quantity) * 100)
: 0;
return {
hour: hourKey,
efficiency: Math.round(efficiency * 10) / 10, // Round to 1 decimal
production_count: data.production_count,
sales_count: 0, // Sales data would need separate hourly aggregation
};
})
.filter((entry) => entry.hour); // Filter out any invalid entries
return result;
},
enabled: false, // Disable until backend endpoint is ready
enabled: !!tenantId && !!activeBatches,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
});
};

View File

@@ -0,0 +1,354 @@
/**
* Clean React Query Hooks for Alert System
*
* NO backward compatibility, uses new type system and alert service
*/
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import type {
EventResponse,
Alert,
PaginatedResponse,
EventsSummary,
EventQueryParams,
} from '../types/events';
import {
getEvents,
getEvent,
getEventsSummary,
acknowledgeAlert,
resolveAlert,
cancelAutoAction,
dismissRecommendation,
recordInteraction,
acknowledgeAlertsByMetadata,
resolveAlertsByMetadata,
type AcknowledgeAlertResponse,
type ResolveAlertResponse,
type CancelAutoActionResponse,
type DismissRecommendationResponse,
type BulkAcknowledgeResponse,
type BulkResolveResponse,
} from '../services/alertService';
// ============================================================
// QUERY KEYS
// ============================================================
export const alertKeys = {
all: ['alerts'] as const,
lists: () => [...alertKeys.all, 'list'] as const,
list: (tenantId: string, params?: EventQueryParams) =>
[...alertKeys.lists(), tenantId, params] as const,
details: () => [...alertKeys.all, 'detail'] as const,
detail: (tenantId: string, eventId: string) =>
[...alertKeys.details(), tenantId, eventId] as const,
summaries: () => [...alertKeys.all, 'summary'] as const,
summary: (tenantId: string) => [...alertKeys.summaries(), tenantId] as const,
};
// ============================================================
// QUERY HOOKS
// ============================================================
/**
* Fetch events list with filtering and pagination
*/
export function useEvents(
tenantId: string,
params?: EventQueryParams,
options?: Omit<
UseQueryOptions<PaginatedResponse<EventResponse>, Error>,
'queryKey' | 'queryFn'
>
) {
return useQuery<PaginatedResponse<EventResponse>, Error>({
queryKey: alertKeys.list(tenantId, params),
queryFn: () => getEvents(tenantId, params),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
}
/**
* Fetch single event by ID
*/
export function useEvent(
tenantId: string,
eventId: string,
options?: Omit<UseQueryOptions<EventResponse, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery<EventResponse, Error>({
queryKey: alertKeys.detail(tenantId, eventId),
queryFn: () => getEvent(tenantId, eventId),
enabled: !!tenantId && !!eventId,
staleTime: 60 * 1000, // 1 minute
...options,
});
}
/**
* Fetch events summary for dashboard
*/
export function useEventsSummary(
tenantId: string,
options?: Omit<UseQueryOptions<EventsSummary, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery<EventsSummary, Error>({
queryKey: alertKeys.summary(tenantId),
queryFn: () => getEventsSummary(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // Refetch every minute
...options,
});
}
// ============================================================
// MUTATION HOOKS - Alerts
// ============================================================
interface UseAcknowledgeAlertOptions {
tenantId: string;
options?: UseMutationOptions<AcknowledgeAlertResponse, Error, string>;
}
/**
* Acknowledge an alert
*/
export function useAcknowledgeAlert({
tenantId,
options,
}: UseAcknowledgeAlertOptions) {
const queryClient = useQueryClient();
return useMutation<AcknowledgeAlertResponse, Error, string>({
mutationFn: (alertId: string) => acknowledgeAlert(tenantId, alertId),
onSuccess: (data, alertId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, alertId),
});
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, alertId, {} as any);
}
},
...options,
});
}
interface UseResolveAlertOptions {
tenantId: string;
options?: UseMutationOptions<ResolveAlertResponse, Error, string>;
}
/**
* Resolve an alert
*/
export function useResolveAlert({ tenantId, options }: UseResolveAlertOptions) {
const queryClient = useQueryClient();
return useMutation<ResolveAlertResponse, Error, string>({
mutationFn: (alertId: string) => resolveAlert(tenantId, alertId),
onSuccess: (data, alertId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, alertId),
});
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, alertId, {} as any);
}
},
...options,
});
}
interface UseCancelAutoActionOptions {
tenantId: string;
options?: UseMutationOptions<CancelAutoActionResponse, Error, string>;
}
/**
* Cancel an alert's auto-action (escalation countdown)
*/
export function useCancelAutoAction({
tenantId,
options,
}: UseCancelAutoActionOptions) {
const queryClient = useQueryClient();
return useMutation<CancelAutoActionResponse, Error, string>({
mutationFn: (alertId: string) => cancelAutoAction(tenantId, alertId),
onSuccess: (data, alertId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, alertId),
});
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, alertId, {} as any);
}
},
...options,
});
}
// ============================================================
// MUTATION HOOKS - Recommendations
// ============================================================
interface UseDismissRecommendationOptions {
tenantId: string;
options?: UseMutationOptions<DismissRecommendationResponse, Error, string>;
}
/**
* Dismiss a recommendation
*/
export function useDismissRecommendation({
tenantId,
options,
}: UseDismissRecommendationOptions) {
const queryClient = useQueryClient();
return useMutation<DismissRecommendationResponse, Error, string>({
mutationFn: (recommendationId: string) =>
dismissRecommendation(tenantId, recommendationId),
onSuccess: (data, recommendationId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, recommendationId),
});
// Call user's onSuccess if provided
options?.onSuccess?.(data, recommendationId, undefined);
},
...options,
});
}
// ============================================================
// MUTATION HOOKS - Bulk Operations
// ============================================================
interface UseBulkAcknowledgeOptions {
tenantId: string;
options?: UseMutationOptions<
BulkAcknowledgeResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>;
}
/**
* Acknowledge multiple alerts by metadata
*/
export function useBulkAcknowledgeAlerts({
tenantId,
options,
}: UseBulkAcknowledgeOptions) {
const queryClient = useQueryClient();
return useMutation<
BulkAcknowledgeResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>({
mutationFn: ({ alertType, metadataFilter }) =>
acknowledgeAlertsByMetadata(tenantId, alertType, metadataFilter),
onSuccess: (data, variables) => {
// Invalidate all alert queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, variables, {} as any);
}
},
...options,
});
}
interface UseBulkResolveOptions {
tenantId: string;
options?: UseMutationOptions<
BulkResolveResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>;
}
/**
* Resolve multiple alerts by metadata
*/
export function useBulkResolveAlerts({
tenantId,
options,
}: UseBulkResolveOptions) {
const queryClient = useQueryClient();
return useMutation<
BulkResolveResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>({
mutationFn: ({ alertType, metadataFilter }) =>
resolveAlertsByMetadata(tenantId, alertType, metadataFilter),
onSuccess: (data, variables) => {
// Invalidate all alert queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, variables, {} as any);
}
},
...options,
});
}
// ============================================================
// MUTATION HOOKS - Interaction Tracking
// ============================================================
interface UseRecordInteractionOptions {
tenantId: string;
options?: UseMutationOptions<
any,
Error,
{ eventId: string; interactionType: string; metadata?: Record<string, any> }
>;
}
/**
* Record user interaction with an event
*/
export function useRecordInteraction({
tenantId,
options,
}: UseRecordInteractionOptions) {
return useMutation<
any,
Error,
{ eventId: string; interactionType: string; metadata?: Record<string, any> }
>({
mutationFn: ({ eventId, interactionType, metadata }) =>
recordInteraction(tenantId, eventId, interactionType, metadata),
...options,
});
}

View File

@@ -0,0 +1,451 @@
/**
* Enterprise Dashboard Hooks
*
* Direct service calls for enterprise network metrics.
* Fetch data from individual microservices and perform client-side aggregation.
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { tenantService } from '../services/tenant';
import { salesService } from '../services/sales';
import { inventoryService } from '../services/inventory';
import { productionService } from '../services/production';
import { distributionService } from '../services/distribution';
import { forecastingService } from '../services/forecasting';
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
import { ProcurementService } from '../services/procurement-service';
// ================================================================
// TYPE DEFINITIONS
// ================================================================
export interface ChildTenant {
id: string;
name: string;
business_name: string;
account_type: string;
parent_tenant_id: string | null;
is_active: boolean;
}
export interface SalesSummary {
total_revenue: number;
total_quantity: number;
total_orders: number;
average_order_value: number;
top_products: Array<{
product_name: string;
quantity_sold: number;
revenue: number;
}>;
}
export interface InventorySummary {
tenant_id: string;
total_value: number;
out_of_stock_count: number;
low_stock_count: number;
adequate_stock_count: number;
total_ingredients: number;
}
export interface ProductionSummary {
tenant_id: string;
total_batches: number;
pending_batches: number;
in_progress_batches: number;
completed_batches: number;
total_planned_quantity: number;
total_actual_quantity: number;
efficiency_rate: number;
}
export interface NetworkSummary {
parent_tenant_id: string;
child_tenant_count: number;
network_sales_30d: number;
production_volume_30d: number;
pending_internal_transfers_count: number;
active_shipments_count: number;
}
export interface ChildPerformance {
rank: number;
tenant_id: string;
outlet_name: string;
metric_value: number;
}
export interface PerformanceRankings {
parent_tenant_id: string;
metric: string;
period_days: number;
rankings: ChildPerformance[];
total_children: number;
}
export interface DistributionOverview {
date: string;
route_sequences: any[]; // Define more specific type as needed
status_counts: Record<string, number>;
}
export interface ForecastSummary {
days_forecast: number;
aggregated_forecasts: Record<string, any>; // Define more specific type as needed
last_updated: string;
}
// ================================================================
// CHILD TENANTS
// ================================================================
/**
* Get list of child tenants for a parent
*/
export const useChildTenants = (
parentTenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<ChildTenant[]> => {
return useQuery({
queryKey: ['tenants', 'children', parentTenantId],
queryFn: async () => {
const response = await tenantService.getChildTenants(parentTenantId);
// Map TenantResponse to ChildTenant
return response.map(tenant => ({
id: tenant.id,
name: tenant.name,
business_name: tenant.name, // TenantResponse uses 'name' as business name
account_type: tenant.business_type, // TenantResponse uses 'business_type'
parent_tenant_id: parentTenantId, // Set from the parent
is_active: tenant.is_active,
}));
},
staleTime: 60000, // 1 min cache (doesn't change often)
enabled: options?.enabled ?? true,
});
};
// ================================================================
// NETWORK SUMMARY (Client-Side Aggregation)
// ================================================================
/**
* Get network summary by aggregating data from multiple services client-side
*/
export const useNetworkSummary = (
parentTenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<NetworkSummary> => {
const { data: childTenants } = useChildTenants(parentTenantId, options);
return useQuery({
queryKey: ['enterprise', 'network-summary', parentTenantId],
queryFn: async () => {
const childTenantIds = (childTenants || []).map((c) => c.id);
const allTenantIds = [parentTenantId, ...childTenantIds];
// Calculate date range for 30-day sales
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
// Fetch all data in parallel using service abstractions
const [salesBatch, productionData, pendingPOs, shipmentsData] = await Promise.all([
// Sales for all tenants (batch)
salesService.getBatchSalesSummary(
allTenantIds,
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
),
// Production volume for parent
productionService.getDashboardSummary(parentTenantId),
// Pending internal transfers (purchase orders marked as internal)
getPendingApprovalPurchaseOrders(parentTenantId, 100),
// Active shipments
distributionService.getShipments(parentTenantId),
]);
// Ensure data are arrays before filtering
const shipmentsList = Array.isArray(shipmentsData) ? shipmentsData : [];
const posList = Array.isArray(pendingPOs) ? pendingPOs : [];
// Aggregate network sales
const networkSales = Object.values(salesBatch).reduce(
(sum: number, summary: any) => sum + (summary?.total_revenue || 0),
0
);
// Count active shipments
const activeStatuses = ['pending', 'in_transit', 'packed'];
const activeShipmentsCount = shipmentsList.filter((s: any) =>
activeStatuses.includes(s.status)
).length;
// Count pending transfers (assuming POs with internal flag)
const pendingTransfers = posList.filter((po: any) =>
po.reference_number?.includes('INTERNAL') || po.notes?.toLowerCase().includes('internal')
).length;
return {
parent_tenant_id: parentTenantId,
child_tenant_count: childTenantIds.length,
network_sales_30d: networkSales,
production_volume_30d: (productionData as any)?.total_value || 0,
pending_internal_transfers_count: pendingTransfers,
active_shipments_count: activeShipmentsCount,
};
},
staleTime: 30000, // 30s cache
enabled: (options?.enabled ?? true) && !!childTenants,
});
};
// ================================================================
// CHILDREN PERFORMANCE (Client-Side Aggregation)
// ================================================================
/**
* Get performance rankings for child tenants
*/
export const useChildrenPerformance = (
parentTenantId: string,
metric: 'sales' | 'inventory_value' | 'production',
periodDays: number = 30,
options?: { enabled?: boolean }
): UseQueryResult<PerformanceRankings> => {
const { data: childTenants } = useChildTenants(parentTenantId, options);
return useQuery({
queryKey: ['enterprise', 'children-performance', parentTenantId, metric, periodDays],
queryFn: async () => {
if (!childTenants || childTenants.length === 0) {
return {
parent_tenant_id: parentTenantId,
metric,
period_days: periodDays,
rankings: [],
total_children: 0,
};
}
const childTenantIds = childTenants.map((c) => c.id);
let batchData: Record<string, any> = {};
if (metric === 'sales') {
// Fetch sales batch
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays);
batchData = await salesService.getBatchSalesSummary(
childTenantIds,
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
);
} else if (metric === 'inventory_value') {
// Fetch inventory batch
batchData = await inventoryService.getBatchInventorySummary(childTenantIds);
} else if (metric === 'production') {
// Fetch production batch
batchData = await productionService.getBatchProductionSummary(childTenantIds);
}
// Build performance data
const performanceData = childTenants.map((child) => {
const summary = batchData[child.id] || {};
let metricValue = 0;
if (metric === 'sales') {
metricValue = summary.total_revenue || 0;
} else if (metric === 'inventory_value') {
metricValue = summary.total_value || 0;
} else if (metric === 'production') {
metricValue = summary.completed_batches || 0;
}
return {
tenant_id: child.id,
outlet_name: child.name,
metric_value: metricValue,
};
});
// Sort by metric value descending
performanceData.sort((a, b) => b.metric_value - a.metric_value);
// Add rankings
const rankings = performanceData.map((data, index) => ({
rank: index + 1,
...data,
}));
return {
parent_tenant_id: parentTenantId,
metric,
period_days: periodDays,
rankings,
total_children: childTenants.length,
};
},
staleTime: 30000, // 30s cache
enabled: (options?.enabled ?? true) && !!childTenants,
});
};
// ================================================================
// DISTRIBUTION OVERVIEW
// ================================================================
/**
* Get distribution overview for enterprise
*/
export const useDistributionOverview = (
parentTenantId: string,
date: string,
options?: { enabled?: boolean }
): UseQueryResult<DistributionOverview> => {
return useQuery({
queryKey: ['enterprise', 'distribution-overview', parentTenantId, date],
queryFn: async () => {
// Get distribution data directly from distribution service
const routes = await distributionService.getRouteSequences(parentTenantId, date);
const shipments = await distributionService.getShipments(parentTenantId, date);
// Count shipment statuses
const statusCounts: Record<string, number> = {};
const shipmentsList = Array.isArray(shipments) ? shipments : [];
for (const shipment of shipmentsList) {
statusCounts[shipment.status] = (statusCounts[shipment.status] || 0) + 1;
}
return {
date,
route_sequences: Array.isArray(routes) ? routes : [],
status_counts: statusCounts,
};
},
staleTime: 30000, // 30s cache
enabled: options?.enabled ?? true,
});
};
// ================================================================
// FORECAST SUMMARY
// ================================================================
/**
* Get aggregated forecast summary for the enterprise network
*/
export const useForecastSummary = (
parentTenantId: string,
daysAhead: number = 7,
options?: { enabled?: boolean }
): UseQueryResult<ForecastSummary> => {
return useQuery({
queryKey: ['enterprise', 'forecast-summary', parentTenantId, daysAhead],
queryFn: async () => {
// Get forecast data directly from forecasting service
// Using existing batch forecasting functionality from the service
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() + 1); // Tomorrow
endDate.setDate(endDate.getDate() + daysAhead); // End of forecast period
// Get forecast data directly from forecasting service
// Get forecasts for the next N days
const forecastsResponse = await forecastingService.getTenantForecasts(parentTenantId, {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
});
// Extract forecast data from response
const forecastItems = forecastsResponse?.items || [];
const aggregated_forecasts: Record<string, any> = {};
// Group forecasts by date
for (const forecast of forecastItems) {
const date = forecast.forecast_date || forecast.date;
if (date) {
aggregated_forecasts[date] = forecast;
}
}
return {
days_forecast: daysAhead,
aggregated_forecasts,
last_updated: new Date().toISOString(),
};
},
staleTime: 300000, // 5 min cache (forecasts don't change very frequently)
enabled: options?.enabled ?? true,
});
};
// ================================================================
// INDIVIDUAL CHILD METRICS (for detailed views)
// ================================================================
/**
* Get sales for a specific child tenant
*/
export const useChildSales = (
tenantId: string,
periodDays: number = 30,
options?: { enabled?: boolean }
): UseQueryResult<SalesSummary> => {
return useQuery({
queryKey: ['sales', 'summary', tenantId, periodDays],
queryFn: async () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays);
return await salesService.getSalesAnalytics(
tenantId,
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
) as any;
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};
/**
* Get inventory for a specific child tenant
*/
export const useChildInventory = (
tenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<InventorySummary> => {
return useQuery({
queryKey: ['inventory', 'summary', tenantId],
queryFn: async () => {
return await inventoryService.getDashboardSummary(tenantId) as any;
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};
/**
* Get production for a specific child tenant
*/
export const useChildProduction = (
tenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<ProductionSummary> => {
return useQuery({
queryKey: ['production', 'summary', tenantId],
queryFn: async () => {
return await productionService.getDashboardSummary(tenantId) as any;
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};

View File

@@ -0,0 +1,97 @@
/**
* Direct Inventory Service Hook
*
* Phase 1 optimization: Call inventory service directly instead of through orchestrator.
* Eliminates duplicate fetches and reduces orchestrator load.
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { getTenantEndpoint } from '../../config/services';
import { apiClient } from '../client';
export interface StockStatus {
category: string;
in_stock: number;
low_stock: number;
out_of_stock: number;
total: number;
}
export interface InventoryOverview {
out_of_stock_count: number;
low_stock_count: number;
adequate_stock_count: number;
total_ingredients: number;
total_value?: number;
tenant_id: string;
timestamp: string;
}
export interface SustainabilityWidget {
waste_reduction_percentage: number;
local_sourcing_percentage: number;
seasonal_usage_percentage: number;
carbon_footprint_score?: number;
}
/**
* Fetch inventory overview directly from inventory service
*/
export const useInventoryOverview = (
tenantId: string,
options?: {
enabled?: boolean;
refetchInterval?: number;
}
): UseQueryResult<InventoryOverview> => {
return useQuery({
queryKey: ['inventory', 'overview', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('inventory', tenantId, 'inventory/dashboard/overview');
return await apiClient.get(url);
},
staleTime: 30000, // 30s cache
refetchInterval: options?.refetchInterval,
enabled: options?.enabled ?? true,
});
};
/**
* Fetch stock status by category directly from inventory service
*/
export const useStockStatusByCategory = (
tenantId: string,
options?: {
enabled?: boolean;
}
): UseQueryResult<StockStatus[]> => {
return useQuery({
queryKey: ['inventory', 'stock-status', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('inventory', tenantId, 'inventory/dashboard/stock-status');
return await apiClient.get(url);
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};
/**
* Fetch sustainability widget data directly from inventory service
*/
export const useSustainabilityWidget = (
tenantId: string,
options?: {
enabled?: boolean;
}
): UseQueryResult<SustainabilityWidget> => {
return useQuery({
queryKey: ['inventory', 'sustainability', 'widget', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('inventory', tenantId, 'sustainability/widget');
return await apiClient.get(url);
},
staleTime: 60000, // 60s cache (changes less frequently)
enabled: options?.enabled ?? true,
});
};

View File

@@ -0,0 +1,81 @@
/**
* Direct Production Service Hook
*
* Phase 1 optimization: Call production service directly instead of through orchestrator.
* Eliminates one network hop and reduces orchestrator load.
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { getTenantEndpoint } from '../../config/services';
import { apiClient } from '../client';
export interface ProductionBatch {
id: string;
product_id: string;
product_name: string;
planned_quantity: number;
actual_quantity?: number;
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'ON_HOLD' | 'CANCELLED';
planned_start_time: string;
planned_end_time: string;
actual_start_time?: string;
actual_end_time?: string;
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
notes?: string;
}
export interface ProductionBatchesResponse {
batches: ProductionBatch[];
total_count: number;
date: string;
}
/**
* Fetch today's production batches directly from production service
*/
export const useProductionBatches = (
tenantId: string,
options?: {
enabled?: boolean;
refetchInterval?: number;
}
): UseQueryResult<ProductionBatchesResponse> => {
return useQuery({
queryKey: ['production', 'batches', 'today', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('production', tenantId, 'production/batches/today');
return await apiClient.get(url);
},
staleTime: 30000, // 30s cache
refetchInterval: options?.refetchInterval,
enabled: options?.enabled ?? true,
});
};
/**
* Fetch production batches by status directly from production service
*/
export const useProductionBatchesByStatus = (
tenantId: string,
status: string,
options?: {
enabled?: boolean;
limit?: number;
}
): UseQueryResult<ProductionBatchesResponse> => {
const limit = options?.limit ?? 100;
return useQuery({
queryKey: ['production', 'batches', 'status', status, tenantId, limit],
queryFn: async () => {
const url = getTenantEndpoint(
'production',
tenantId,
`production/batches?status=${status}&limit=${limit}`
);
return await apiClient.get(url);
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
import { useState, useEffect, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Alert, AlertQueryParams } from '../types/events';
import {
useEvents,
useEventsSummary,
useAcknowledgeAlert,
useResolveAlert,
useCancelAutoAction,
useBulkAcknowledgeAlerts,
useBulkResolveAlerts
} from './useAlerts';
import { useSSEEvents } from '../../hooks/useSSE';
import { AlertFilterOptions, applyAlertFilters } from '../../utils/alertManagement';
interface UseUnifiedAlertsConfig {
refetchInterval?: number;
enableSSE?: boolean;
sseChannels?: string[];
}
interface UseUnifiedAlertsReturn {
alerts: Alert[];
filteredAlerts: Alert[];
stats: any;
filters: AlertFilterOptions;
setFilters: (filters: AlertFilterOptions) => void;
search: string;
setSearch: (search: string) => void;
isLoading: boolean;
isRefetching: boolean;
error: Error | null;
refetch: () => void;
acknowledgeAlert: (alertId: string) => Promise<void>;
resolveAlert: (alertId: string) => Promise<void>;
cancelAutoAction: (alertId: string) => Promise<void>;
acknowledgeAlertsByMetadata: (alertType: string, metadata: any) => Promise<void>;
resolveAlertsByMetadata: (alertType: string, metadata: any) => Promise<void>;
isSSEConnected: boolean;
sseError: Error | null;
}
export function useUnifiedAlerts(
tenantId: string,
initialFilters: AlertFilterOptions = {},
config: UseUnifiedAlertsConfig = {}
): UseUnifiedAlertsReturn {
const [filters, setFilters] = useState<AlertFilterOptions>(initialFilters);
const [search, setSearch] = useState('');
// Fetch alerts and summary
const {
data: alertsData,
isLoading,
isRefetching,
error,
refetch
} = useEvents(tenantId, filters as AlertQueryParams);
const { data: summaryData } = useEventsSummary(tenantId);
// Alert mutations
const acknowledgeMutation = useAcknowledgeAlert({ tenantId });
const resolveMutation = useResolveAlert({ tenantId });
const cancelAutoActionMutation = useCancelAutoAction({ tenantId });
const bulkAcknowledgeMutation = useBulkAcknowledgeAlerts({ tenantId });
const bulkResolveMutation = useBulkResolveAlerts({ tenantId });
// SSE connection for real-time updates
const [isSSEConnected, setSSEConnected] = useState(false);
const [sseError, setSSEError] = useState<Error | null>(null);
// Enable SSE if configured
if (config.enableSSE) {
useSSEEvents({
channels: config.sseChannels || [`*.alerts`, `*.notifications`],
});
}
// Process alerts data
const allAlerts: Alert[] = alertsData?.items || [];
// Apply filters and search
const filteredAlerts = applyAlertFilters(allAlerts, filters, search);
// Mutation functions
const handleAcknowledgeAlert = async (alertId: string) => {
await acknowledgeMutation.mutateAsync(alertId);
refetch();
};
const handleResolveAlert = async (alertId: string) => {
await resolveMutation.mutateAsync(alertId);
refetch();
};
const handleCancelAutoAction = async (alertId: string) => {
await cancelAutoActionMutation.mutateAsync(alertId);
};
const handleAcknowledgeAlertsByMetadata = async (alertType: string, metadata: any) => {
await bulkAcknowledgeMutation.mutateAsync({
alertType,
metadataFilter: metadata
});
refetch();
};
const handleResolveAlertsByMetadata = async (alertType: string, metadata: any) => {
await bulkResolveMutation.mutateAsync({
alertType,
metadataFilter: metadata
});
refetch();
};
return {
alerts: allAlerts,
filteredAlerts,
stats: summaryData,
filters,
setFilters,
search,
setSearch,
isLoading,
isRefetching,
error: error || null,
refetch,
acknowledgeAlert: handleAcknowledgeAlert,
resolveAlert: handleResolveAlert,
cancelAutoAction: handleCancelAutoAction,
acknowledgeAlertsByMetadata: handleAcknowledgeAlertsByMetadata,
resolveAlertsByMetadata: handleResolveAlertsByMetadata,
isSSEConnected,
sseError,
};
}
// Additional hooks that may be used with unified alerts
export function useSingleAlert(tenantId: string, alertId: string) {
return useEvent(tenantId, alertId);
}
export function useAlertStats(tenantId: string) {
return useEventsSummary(tenantId);
}
export function useRealTimeAlerts(tenantId: string, channels?: string[]) {
const { notifications } = useSSEEvents({
channels: channels || [`*.alerts`, `*.notifications`, `*.recommendations`],
});
return { realTimeAlerts: notifications };
}

View File

@@ -18,7 +18,7 @@ export { inventoryService } from './services/inventory';
// New API Services
export { trainingService } from './services/training';
export { alertProcessorService } from './services/alert_processor';
export { alertService as alertProcessorService } from './services/alertService';
export { suppliersService } from './services/suppliers';
export { OrdersService } from './services/orders';
export { forecastingService } from './services/forecasting';
@@ -196,11 +196,8 @@ export { TrainingStatus } from './types/training';
// Types - Alert Processor
export type {
AlertMessage,
AlertResponse,
AlertUpdateRequest,
AlertQueryParams,
AlertDashboardData,
EventResponse as AlertResponse,
EventQueryParams as AlertQueryParams,
NotificationSettings,
ChannelRoutingConfig,
WebhookConfig,
@@ -208,15 +205,9 @@ export type {
ProcessingMetrics,
AlertAction,
BusinessHours,
} from './types/alert_processor';
} from './types/events';
export {
AlertItemType,
AlertType,
AlertSeverity,
AlertService,
NotificationChannel,
} from './types/alert_processor';
// No need for additional enums as they are included in events.ts
// Types - Suppliers
export type {
@@ -560,29 +551,26 @@ export {
// Hooks - Alert Processor
export {
useAlerts,
useAlert,
useAlertDashboardData,
useAlertProcessingStatus,
useNotificationSettings,
useChannelRoutingConfig,
useWebhooks,
useProcessingMetrics,
useUpdateAlert,
useDismissAlert,
useEvents as useAlerts,
useEvent as useAlert,
useEventsSummary as useAlertDashboardData,
useAcknowledgeAlert,
useResolveAlert,
useUpdateNotificationSettings,
useCreateWebhook,
useUpdateWebhook,
useDeleteWebhook,
useTestWebhook,
useAlertSSE,
useActiveAlertsCount,
useAlertsByPriority,
useUnreadAlertsCount,
alertProcessorKeys,
} from './hooks/alert_processor';
useCancelAutoAction,
useDismissRecommendation,
useBulkAcknowledgeAlerts,
useBulkResolveAlerts,
useRecordInteraction,
alertKeys as alertProcessorKeys,
} from './hooks/useAlerts';
// Hooks - Unified Alerts
export {
useUnifiedAlerts,
useSingleAlert,
useAlertStats,
useRealTimeAlerts,
} from './hooks/useUnifiedAlerts';
// Hooks - Suppliers
export {
@@ -738,29 +726,60 @@ export {
useRunDailyWorkflow,
} from './hooks/orchestrator';
// Hooks - New Dashboard (JTBD-aligned)
// Hooks - Professional Dashboard (JTBD-aligned)
export {
useBakeryHealthStatus,
useOrchestrationSummary,
useActionQueue,
useProductionTimeline,
useInsights,
useApprovePurchaseOrder as useApprovePurchaseOrderDashboard,
useDismissAlert as useDismissAlertDashboard,
useStartProductionBatch,
usePauseProductionBatch,
} from './hooks/newDashboard';
useExecutionProgress,
useUnifiedActionQueue,
} from './hooks/useProfessionalDashboard';
export type {
BakeryHealthStatus,
HealthChecklistItem,
HeadlineData,
ReasoningInputs,
PurchaseOrderSummary,
ProductionBatchSummary,
OrchestrationSummary,
ActionQueue,
ActionButton,
ActionItem,
ActionQueue,
ProductionTimeline,
ProductionTimelineItem,
Insights,
InsightCard,
} from './hooks/newDashboard';
Insights,
UnifiedActionQueue,
EnrichedAlert,
} from './hooks/useProfessionalDashboard';
// Hooks - Enterprise Dashboard
export {
useNetworkSummary,
useChildrenPerformance,
useDistributionOverview,
useForecastSummary,
useChildSales,
useChildInventory,
useChildProduction,
useChildTenants,
} from './hooks/useEnterpriseDashboard';
export type {
NetworkSummary,
PerformanceRankings,
ChildPerformance,
DistributionOverview,
ForecastSummary,
ChildTenant,
SalesSummary,
InventorySummary,
ProductionSummary,
} from './hooks/useEnterpriseDashboard';
// Note: All query key factories are already exported in their respective hook sections above

View File

@@ -0,0 +1,253 @@
/**
* Clean Alert Service - Matches Backend API Exactly
*
* Backend API: /services/alert_processor/app/api/alerts_clean.py
*
* NO backward compatibility, uses new type system from /api/types/events.ts
*/
import { apiClient } from '../client';
import type {
EventResponse,
Alert,
Notification,
Recommendation,
PaginatedResponse,
EventsSummary,
EventQueryParams,
} from '../types/events';
const BASE_PATH = '/tenants';
// ============================================================
// QUERY METHODS
// ============================================================
/**
* Get events list with filtering and pagination
*/
export async function getEvents(
tenantId: string,
params?: EventQueryParams
): Promise<PaginatedResponse<EventResponse>> {
return await apiClient.get<PaginatedResponse<EventResponse>>(
`${BASE_PATH}/${tenantId}/alerts`,
{ params }
);
}
/**
* Get single event by ID
*/
export async function getEvent(
tenantId: string,
eventId: string
): Promise<EventResponse> {
return await apiClient.get<EventResponse>(
`${BASE_PATH}/${tenantId}/alerts/${eventId}`
);
}
/**
* Get events summary for dashboard
*/
export async function getEventsSummary(
tenantId: string
): Promise<EventsSummary> {
return await apiClient.get<EventsSummary>(
`${BASE_PATH}/${tenantId}/alerts/summary`
);
}
// ============================================================
// MUTATION METHODS - Alerts
// ============================================================
export interface AcknowledgeAlertResponse {
success: boolean;
event_id: string;
status: string;
}
/**
* Acknowledge an alert
*/
export async function acknowledgeAlert(
tenantId: string,
alertId: string
): Promise<AcknowledgeAlertResponse> {
return await apiClient.post<AcknowledgeAlertResponse>(
`${BASE_PATH}/${tenantId}/alerts/${alertId}/acknowledge`
);
}
export interface ResolveAlertResponse {
success: boolean;
event_id: string;
status: string;
resolved_at: string;
}
/**
* Resolve an alert
*/
export async function resolveAlert(
tenantId: string,
alertId: string
): Promise<ResolveAlertResponse> {
return await apiClient.post<ResolveAlertResponse>(
`${BASE_PATH}/${tenantId}/alerts/${alertId}/resolve`
);
}
export interface CancelAutoActionResponse {
success: boolean;
event_id: string;
message: string;
updated_type_class: string;
}
/**
* Cancel an alert's auto-action (escalation countdown)
*/
export async function cancelAutoAction(
tenantId: string,
alertId: string
): Promise<CancelAutoActionResponse> {
return await apiClient.post<CancelAutoActionResponse>(
`${BASE_PATH}/${tenantId}/alerts/${alertId}/cancel-auto-action`
);
}
// ============================================================
// MUTATION METHODS - Recommendations
// ============================================================
export interface DismissRecommendationResponse {
success: boolean;
event_id: string;
dismissed_at: string;
}
/**
* Dismiss a recommendation
*/
export async function dismissRecommendation(
tenantId: string,
recommendationId: string
): Promise<DismissRecommendationResponse> {
return await apiClient.post<DismissRecommendationResponse>(
`${BASE_PATH}/${tenantId}/recommendations/${recommendationId}/dismiss`
);
}
// ============================================================
// INTERACTION TRACKING
// ============================================================
export interface RecordInteractionResponse {
success: boolean;
interaction_id: string;
event_id: string;
interaction_type: string;
}
/**
* Record user interaction with an event (for analytics)
*/
export async function recordInteraction(
tenantId: string,
eventId: string,
interactionType: string,
metadata?: Record<string, any>
): Promise<RecordInteractionResponse> {
return await apiClient.post<RecordInteractionResponse>(
`${BASE_PATH}/${tenantId}/events/${eventId}/interactions`,
{
interaction_type: interactionType,
interaction_metadata: metadata,
}
);
}
// ============================================================
// BULK OPERATIONS (by metadata)
// ============================================================
export interface BulkAcknowledgeResponse {
success: boolean;
acknowledged_count: number;
alert_ids: string[];
}
/**
* Acknowledge multiple alerts by metadata filter
*/
export async function acknowledgeAlertsByMetadata(
tenantId: string,
alertType: string,
metadataFilter: Record<string, any>
): Promise<BulkAcknowledgeResponse> {
return await apiClient.post<BulkAcknowledgeResponse>(
`${BASE_PATH}/${tenantId}/alerts/bulk-acknowledge`,
{
alert_type: alertType,
metadata_filter: metadataFilter,
}
);
}
export interface BulkResolveResponse {
success: boolean;
resolved_count: number;
alert_ids: string[];
}
/**
* Resolve multiple alerts by metadata filter
*/
export async function resolveAlertsByMetadata(
tenantId: string,
alertType: string,
metadataFilter: Record<string, any>
): Promise<BulkResolveResponse> {
return await apiClient.post<BulkResolveResponse>(
`${BASE_PATH}/${tenantId}/alerts/bulk-resolve`,
{
alert_type: alertType,
metadata_filter: metadataFilter,
}
);
}
// ============================================================
// EXPORT AS NAMED OBJECT
// ============================================================
export const alertService = {
// Query
getEvents,
getEvent,
getEventsSummary,
// Alert mutations
acknowledgeAlert,
resolveAlert,
cancelAutoAction,
// Recommendation mutations
dismissRecommendation,
// Interaction tracking
recordInteraction,
// Bulk operations
acknowledgeAlertsByMetadata,
resolveAlertsByMetadata,
};
// ============================================================
// DEFAULT EXPORT
// ============================================================
export default alertService;

View File

@@ -1,275 +0,0 @@
/**
* Alert Processor service API implementation
* Note: Alert Processor is a background service that doesn't expose direct HTTP APIs
* This service provides utilities and types for working with alert processing
*/
import { apiClient } from '../client/apiClient';
import type {
AlertMessage,
AlertResponse,
AlertUpdateRequest,
AlertFilters,
AlertQueryParams,
AlertDashboardData,
NotificationSettings,
ChannelRoutingConfig,
WebhookConfig,
WebhookPayload,
AlertProcessingStatus,
ProcessingMetrics,
SSEAlertMessage,
PaginatedResponse,
} from '../types/alert_processor';
class AlertProcessorService {
private readonly baseUrl = '/alerts';
private readonly notificationUrl = '/notifications';
private readonly webhookUrl = '/webhooks';
// Alert Management (these would be exposed via other services like inventory, production, etc.)
async getAlerts(
tenantId: string,
queryParams?: AlertQueryParams
): Promise<PaginatedResponse<AlertResponse>> {
const params = new URLSearchParams();
if (queryParams?.severity?.length) {
queryParams.severity.forEach(s => params.append('severity', s));
}
if (queryParams?.type?.length) {
queryParams.type.forEach(t => params.append('type', t));
}
if (queryParams?.service?.length) {
queryParams.service.forEach(s => params.append('service', s));
}
if (queryParams?.item_type?.length) {
queryParams.item_type.forEach(it => params.append('item_type', it));
}
if (queryParams?.date_from) params.append('date_from', queryParams.date_from);
if (queryParams?.date_to) params.append('date_to', queryParams.date_to);
if (queryParams?.status) params.append('status', queryParams.status);
if (queryParams?.search) params.append('search', queryParams.search);
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
if (queryParams?.sort_by) params.append('sort_by', queryParams.sort_by);
if (queryParams?.sort_order) params.append('sort_order', queryParams.sort_order);
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<PaginatedResponse<AlertResponse>>(
`${this.baseUrl}/tenants/${tenantId}${queryString}`
);
}
async getAlert(tenantId: string, alertId: string): Promise<AlertResponse> {
return apiClient.get<AlertResponse>(
`${this.baseUrl}/tenants/${tenantId}/${alertId}`
);
}
async updateAlert(
tenantId: string,
alertId: string,
updateData: AlertUpdateRequest
): Promise<AlertResponse> {
return apiClient.put<AlertResponse>(
`${this.baseUrl}/tenants/${tenantId}/${alertId}`,
updateData
);
}
async dismissAlert(tenantId: string, alertId: string): Promise<AlertResponse> {
return apiClient.put<AlertResponse>(
`${this.baseUrl}/tenants/${tenantId}/${alertId}`,
{ status: 'dismissed' }
);
}
async acknowledgeAlert(
tenantId: string,
alertId: string,
notes?: string
): Promise<AlertResponse> {
return apiClient.put<AlertResponse>(
`${this.baseUrl}/tenants/${tenantId}/${alertId}`,
{ status: 'acknowledged', notes }
);
}
async resolveAlert(
tenantId: string,
alertId: string,
notes?: string
): Promise<AlertResponse> {
return apiClient.put<AlertResponse>(
`${this.baseUrl}/tenants/${tenantId}/${alertId}`,
{ status: 'resolved', notes }
);
}
// Dashboard Data
async getDashboardData(tenantId: string): Promise<AlertDashboardData> {
return apiClient.get<AlertDashboardData>(
`${this.baseUrl}/tenants/${tenantId}/dashboard`
);
}
// Notification Settings
async getNotificationSettings(tenantId: string): Promise<NotificationSettings> {
return apiClient.get<NotificationSettings>(
`${this.notificationUrl}/tenants/${tenantId}/settings`
);
}
async updateNotificationSettings(
tenantId: string,
settings: Partial<NotificationSettings>
): Promise<NotificationSettings> {
return apiClient.put<NotificationSettings>(
`${this.notificationUrl}/tenants/${tenantId}/settings`,
settings
);
}
async getChannelRoutingConfig(): Promise<ChannelRoutingConfig> {
return apiClient.get<ChannelRoutingConfig>(`${this.notificationUrl}/routing-config`);
}
// Webhook Management
async getWebhooks(tenantId: string): Promise<WebhookConfig[]> {
return apiClient.get<WebhookConfig[]>(`${this.webhookUrl}/tenants/${tenantId}`);
}
async createWebhook(
tenantId: string,
webhook: Omit<WebhookConfig, 'tenant_id'>
): Promise<WebhookConfig> {
return apiClient.post<WebhookConfig>(
`${this.webhookUrl}/tenants/${tenantId}`,
{ ...webhook, tenant_id: tenantId }
);
}
async updateWebhook(
tenantId: string,
webhookId: string,
webhook: Partial<WebhookConfig>
): Promise<WebhookConfig> {
return apiClient.put<WebhookConfig>(
`${this.webhookUrl}/tenants/${tenantId}/${webhookId}`,
webhook
);
}
async deleteWebhook(tenantId: string, webhookId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`${this.webhookUrl}/tenants/${tenantId}/${webhookId}`
);
}
async testWebhook(
tenantId: string,
webhookId: string
): Promise<{ success: boolean; message: string }> {
return apiClient.post<{ success: boolean; message: string }>(
`${this.webhookUrl}/tenants/${tenantId}/${webhookId}/test`
);
}
// Processing Status and Metrics
async getProcessingStatus(
tenantId: string,
alertId: string
): Promise<AlertProcessingStatus> {
return apiClient.get<AlertProcessingStatus>(
`${this.baseUrl}/tenants/${tenantId}/${alertId}/processing-status`
);
}
async getProcessingMetrics(tenantId: string): Promise<ProcessingMetrics> {
return apiClient.get<ProcessingMetrics>(
`${this.baseUrl}/tenants/${tenantId}/processing-metrics`
);
}
// SSE (Server-Sent Events) connection helpers
getSSEUrl(tenantId: string): string {
const baseUrl = apiClient.getAxiosInstance().defaults.baseURL;
return `${baseUrl}/sse/tenants/${tenantId}/alerts`;
}
createSSEConnection(tenantId: string, token?: string): EventSource {
const sseUrl = this.getSSEUrl(tenantId);
const urlWithToken = token ? `${sseUrl}?token=${token}` : sseUrl;
return new EventSource(urlWithToken);
}
// Utility methods for working with alerts
static formatAlertMessage(alert: AlertMessage): string {
return `[${alert.severity.toUpperCase()}] ${alert.title}: ${alert.message}`;
}
static getAlertIcon(alert: AlertMessage): string {
const iconMap: Record<string, string> = {
inventory_low: '📦',
quality_issue: '⚠️',
delivery_delay: '🚚',
production_delay: '🏭',
equipment_failure: '🔧',
food_safety: '🦠',
temperature_alert: '🌡️',
expiry_warning: '⏰',
forecast_accuracy: '📊',
demand_spike: '📈',
supplier_issue: '🏢',
cost_optimization: '💰',
revenue_opportunity: '💡',
};
return iconMap[alert.type] || '🔔';
}
static getSeverityColor(severity: string): string {
const colorMap: Record<string, string> = {
urgent: '#dc2626', // red-600
high: '#ea580c', // orange-600
medium: '#d97706', // amber-600
low: '#65a30d', // lime-600
};
return colorMap[severity] || '#6b7280'; // gray-500
}
// Message queuing helpers (for RabbitMQ integration)
static createAlertMessage(
tenantId: string,
alert: Omit<AlertMessage, 'id' | 'tenant_id' | 'timestamp'>
): AlertMessage {
return {
id: `alert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
tenant_id: tenantId,
timestamp: new Date().toISOString(),
...alert,
};
}
static validateWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
// This would typically use crypto.createHmac for HMAC-SHA256 verification
// Implementation depends on the specific signature algorithm used
const crypto = window.crypto || (window as any).msCrypto;
if (!crypto?.subtle) {
console.warn('WebCrypto API not available for signature verification');
return false;
}
// Simplified example - actual implementation would use proper HMAC verification
return signature.length > 0 && secret.length > 0;
}
}
// Create and export singleton instance
export const alertProcessorService = new AlertProcessorService();
export default alertProcessorService;

View File

@@ -0,0 +1,62 @@
// ================================================================
// frontend/src/api/services/distribution.ts
// ================================================================
/**
* Distribution Service - Complete backend alignment
*
* Backend API structure:
* - services/distribution/app/api/routes.py
* - services/distribution/app/api/shipments.py
*
* Last Updated: 2025-12-03
* Status: ✅ Complete - Backend alignment
*/
import { apiClient } from '../client';
export class DistributionService {
private readonly baseUrl = '/tenants';
// ===================================================================
// SHIPMENTS
// Backend: services/distribution/app/api/shipments.py
// ===================================================================
async getShipments(
tenantId: string,
date?: string
): Promise<any[]> {
const params = new URLSearchParams();
if (date) params.append('date', date);
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/distribution/shipments${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<any>(url);
return response.shipments || response;
}
async getShipment(
tenantId: string,
shipmentId: string
): Promise<any> {
return apiClient.get(`${this.baseUrl}/${tenantId}/distribution/shipments/${shipmentId}`);
}
async getRouteSequences(
tenantId: string,
date?: string
): Promise<any[]> {
const params = new URLSearchParams();
if (date) params.append('date', date);
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/distribution/routes${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<any>(url);
return response.routes || response;
}
}
export const distributionService = new DistributionService();
export default distributionService;

View File

@@ -1,104 +0,0 @@
import { apiClient } from '../client';
export interface NetworkSummary {
parent_tenant_id: string;
total_tenants: number;
child_tenant_count: number;
total_revenue: number;
network_sales_30d: number;
active_alerts: number;
efficiency_score: number;
growth_rate: number;
production_volume_30d: number;
pending_internal_transfers_count: number;
active_shipments_count: number;
last_updated: string;
}
export interface ChildPerformance {
rankings: Array<{
tenant_id: string;
name: string;
anonymized_name: string;
metric_value: number;
rank: number;
}>;
}
export interface DistributionOverview {
route_sequences: any[];
status_counts: {
pending: number;
in_transit: number;
delivered: number;
failed: number;
[key: string]: number;
};
}
export interface ForecastSummary {
aggregated_forecasts: Record<string, any>;
days_forecast: number;
last_updated: string;
}
export interface NetworkPerformance {
metrics: Record<string, any>;
}
export class EnterpriseService {
private readonly baseUrl = '/tenants';
async getNetworkSummary(tenantId: string): Promise<NetworkSummary> {
return apiClient.get<NetworkSummary>(`${this.baseUrl}/${tenantId}/enterprise/network-summary`);
}
async getChildrenPerformance(
tenantId: string,
metric: string = 'sales',
periodDays: number = 30
): Promise<ChildPerformance> {
const queryParams = new URLSearchParams({
metric,
period_days: periodDays.toString()
});
return apiClient.get<ChildPerformance>(
`${this.baseUrl}/${tenantId}/enterprise/children-performance?${queryParams.toString()}`
);
}
async getDistributionOverview(tenantId: string, targetDate?: string): Promise<DistributionOverview> {
const queryParams = new URLSearchParams();
if (targetDate) {
queryParams.append('target_date', targetDate);
}
return apiClient.get<DistributionOverview>(
`${this.baseUrl}/${tenantId}/enterprise/distribution-overview?${queryParams.toString()}`
);
}
async getForecastSummary(tenantId: string, daysAhead: number = 7): Promise<ForecastSummary> {
const queryParams = new URLSearchParams({
days_ahead: daysAhead.toString()
});
return apiClient.get<ForecastSummary>(
`${this.baseUrl}/${tenantId}/enterprise/forecast-summary?${queryParams.toString()}`
);
}
async getNetworkPerformance(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<NetworkPerformance> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
return apiClient.get<NetworkPerformance>(
`${this.baseUrl}/${tenantId}/enterprise/network-performance?${queryParams.toString()}`
);
}
}
export const enterpriseService = new EnterpriseService();

View File

@@ -19,20 +19,18 @@ class ExternalDataService {
* List all supported cities
*/
async listCities(): Promise<CityInfoResponse[]> {
const response = await apiClient.get<CityInfoResponse[]>(
return await apiClient.get<CityInfoResponse[]>(
'/api/v1/external/cities'
);
return response.data;
}
/**
* Get data availability for a specific city
*/
async getCityAvailability(cityId: string): Promise<DataAvailabilityResponse> {
const response = await apiClient.get<DataAvailabilityResponse>(
return await apiClient.get<DataAvailabilityResponse>(
`/api/v1/external/operations/cities/${cityId}/availability`
);
return response.data;
}
/**
@@ -47,11 +45,10 @@ class ExternalDataService {
end_date: string;
}
): Promise<WeatherDataResponse[]> {
const response = await apiClient.get<WeatherDataResponse[]>(
return await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-weather-optimized`,
{ params }
);
return response.data;
}
/**
@@ -66,11 +63,10 @@ class ExternalDataService {
end_date: string;
}
): Promise<TrafficDataResponse[]> {
const response = await apiClient.get<TrafficDataResponse[]>(
return await apiClient.get<TrafficDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-traffic-optimized`,
{ params }
);
return response.data;
}
/**
@@ -83,11 +79,10 @@ class ExternalDataService {
longitude: number;
}
): Promise<WeatherDataResponse> {
const response = await apiClient.get<WeatherDataResponse>(
return await apiClient.get<WeatherDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/weather/current`,
{ params }
);
return response.data;
}
/**
@@ -101,11 +96,10 @@ class ExternalDataService {
days?: number;
}
): Promise<WeatherDataResponse[]> {
const response = await apiClient.get<WeatherDataResponse[]>(
return await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/weather/forecast`,
{ params }
);
return response.data;
}
/**
@@ -118,11 +112,10 @@ class ExternalDataService {
longitude: number;
}
): Promise<TrafficDataResponse> {
const response = await apiClient.get<TrafficDataResponse>(
return await apiClient.get<TrafficDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/traffic/current`,
{ params }
);
return response.data;
}
}

View File

@@ -393,6 +393,20 @@ export class InventoryService {
);
}
// ===================================================================
// OPERATIONS: Batch Inventory Summary (Enterprise Feature)
// Backend: services/inventory/app/api/inventory_operations.py
// ===================================================================
async getBatchInventorySummary(tenantIds: string[]): Promise<Record<string, any>> {
return apiClient.post<Record<string, any>>(
'/tenants/batch/inventory-summary',
{
tenant_ids: tenantIds,
}
);
}
// ===================================================================
// OPERATIONS: Food Safety
// Backend: services/inventory/app/api/food_safety_operations.py

View File

@@ -43,7 +43,7 @@ class NominatimService {
}
try {
const response = await apiClient.get<NominatimResult[]>(`${this.baseUrl}/search`, {
return await apiClient.get<NominatimResult[]>(`${this.baseUrl}/search`, {
params: {
q: query,
format: 'json',
@@ -52,8 +52,6 @@ class NominatimService {
countrycodes: 'es', // Spain only
},
});
return response.data;
} catch (error) {
console.error('Address search failed:', error);
return [];

View File

@@ -9,97 +9,26 @@
*/
import { apiClient } from '../client';
import {
OrchestratorWorkflowRequest,
OrchestratorWorkflowResponse,
WorkflowExecutionSummary,
WorkflowExecutionDetail,
OrchestratorStatus,
OrchestratorConfig,
WorkflowStepResult
} from '../types/orchestrator';
// ============================================================================
// ORCHESTRATOR WORKFLOW TYPES
// ============================================================================
export interface OrchestratorWorkflowRequest {
target_date?: string; // YYYY-MM-DD, defaults to tomorrow
planning_horizon_days?: number; // Default: 14
// Forecasting options
forecast_days_ahead?: number; // Default: 7
// Production options
auto_schedule_production?: boolean; // Default: true
production_planning_days?: number; // Default: 1
// Procurement options
auto_create_purchase_orders?: boolean; // Default: true
auto_approve_purchase_orders?: boolean; // Default: false
safety_stock_percentage?: number; // Default: 20.00
// Orchestrator options
skip_on_error?: boolean; // Continue to next step if one fails
notify_on_completion?: boolean; // Send notification when done
}
export interface WorkflowStepResult {
step: 'forecasting' | 'production' | 'procurement';
status: 'success' | 'failed' | 'skipped';
duration_ms: number;
data?: any;
error?: string;
warnings?: string[];
}
export interface OrchestratorWorkflowResponse {
success: boolean;
workflow_id: string;
tenant_id: string;
target_date: string;
execution_date: string;
total_duration_ms: number;
steps: WorkflowStepResult[];
// Step-specific results
forecast_result?: {
forecast_id: string;
total_forecasts: number;
forecast_data: any;
};
production_result?: {
schedule_id: string;
total_batches: number;
total_quantity: number;
};
procurement_result?: {
plan_id: string;
total_requirements: number;
total_cost: string;
purchase_orders_created: number;
purchase_orders_auto_approved: number;
};
warnings?: string[];
errors?: string[];
}
export interface WorkflowExecutionSummary {
id: string;
tenant_id: string;
target_date: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
started_at: string;
completed_at?: string;
total_duration_ms?: number;
steps_completed: number;
steps_total: number;
created_by?: string;
}
export interface WorkflowExecutionDetail extends WorkflowExecutionSummary {
steps: WorkflowStepResult[];
forecast_id?: string;
production_schedule_id?: string;
procurement_plan_id?: string;
warnings?: string[];
errors?: string[];
}
// Re-export types for backward compatibility
export type {
OrchestratorWorkflowRequest,
OrchestratorWorkflowResponse,
WorkflowExecutionSummary,
WorkflowExecutionDetail,
OrchestratorStatus,
OrchestratorConfig,
WorkflowStepResult
};
// ============================================================================
// ORCHESTRATOR WORKFLOW API FUNCTIONS
@@ -230,21 +159,6 @@ export async function retryWorkflowExecution(
// ORCHESTRATOR STATUS & HEALTH
// ============================================================================
export interface OrchestratorStatus {
is_leader: boolean;
scheduler_running: boolean;
next_scheduled_run?: string;
last_execution?: {
id: string;
target_date: string;
status: string;
completed_at: string;
};
total_executions_today: number;
total_successful_executions: number;
total_failed_executions: number;
}
/**
* Get orchestrator service status
*/
@@ -256,22 +170,21 @@ export async function getOrchestratorStatus(
);
}
/**
* Get timestamp of last orchestration run
*/
export async function getLastOrchestrationRun(
tenantId: string
): Promise<{ timestamp: string | null; runNumber: number | null }> {
return apiClient.get<{ timestamp: string | null; runNumber: number | null }>(
`/tenants/${tenantId}/orchestrator/last-run`
);
}
// ============================================================================
// ORCHESTRATOR CONFIGURATION
// ============================================================================
export interface OrchestratorConfig {
enabled: boolean;
schedule_cron: string; // Cron expression for daily run
default_planning_horizon_days: number;
auto_create_purchase_orders: boolean;
auto_approve_purchase_orders: boolean;
safety_stock_percentage: number;
notify_on_completion: boolean;
notify_on_failure: boolean;
skip_on_error: boolean;
}
/**
* Get orchestrator configuration for tenant
*/

View File

@@ -445,6 +445,24 @@ export class ProcurementService {
{}
);
}
/**
* Get expected deliveries
* GET /api/v1/tenants/{tenant_id}/procurement/expected-deliveries
*/
static async getExpectedDeliveries(
tenantId: string,
params?: { days_ahead?: number; include_overdue?: boolean }
): Promise<{ deliveries: any[]; total_count: number }> {
const queryParams = new URLSearchParams();
if (params?.days_ahead !== undefined) queryParams.append('days_ahead', params.days_ahead.toString());
if (params?.include_overdue !== undefined) queryParams.append('include_overdue', params.include_overdue.toString());
const queryString = queryParams.toString();
const url = `/tenants/${tenantId}/procurement/expected-deliveries${queryString ? `?${queryString}` : ''}`;
return apiClient.get<{ deliveries: any[]; total_count: number }>(url);
}
}
export default ProcurementService;

View File

@@ -118,6 +118,7 @@ export class ProductionService {
return apiClient.get<BatchStatistics>(url);
}
// ===================================================================
// ATOMIC: Production Schedules CRUD
// Backend: services/production/app/api/production_schedules.py
@@ -406,6 +407,20 @@ export class ProductionService {
return apiClient.get(url);
}
// ===================================================================
// ANALYTICS: Batch Production Summary (Enterprise Feature)
// Backend: services/production/app/api/analytics.py
// ===================================================================
async getBatchProductionSummary(tenantIds: string[]): Promise<Record<string, any>> {
return apiClient.post<Record<string, any>>(
'/tenants/batch/production-summary',
{
tenant_ids: tenantIds,
}
);
}
// ===================================================================
// OPERATIONS: Scheduler
// ===================================================================

View File

@@ -196,6 +196,26 @@ export class SalesService {
);
}
// ===================================================================
// OPERATIONS: Batch Sales Summary (Enterprise Feature)
// Backend: services/sales/app/api/sales_operations.py
// ===================================================================
async getBatchSalesSummary(
tenantIds: string[],
startDate: string,
endDate: string
): Promise<Record<string, any>> {
return apiClient.post<Record<string, any>>(
'/tenants/batch/sales-summary',
{
tenant_ids: tenantIds,
start_date: startDate,
end_date: endDate,
}
);
}
// ===================================================================
// OPERATIONS: Aggregation
// Backend: services/sales/app/api/sales_operations.py

View File

@@ -380,6 +380,8 @@ export class SubscriptionService {
is_read_only: boolean;
cancellation_effective_date: string | null;
days_until_inactive: number | null;
billing_cycle?: string;
next_billing_date?: string;
}> {
return apiClient.get(`/subscriptions/${tenantId}/status`);
}
@@ -483,10 +485,10 @@ export class SubscriptionService {
return {
tier: status.plan as SubscriptionTier,
billing_cycle: 'monthly', // TODO: Get from actual subscription data
billing_cycle: (status.billing_cycle as 'monthly' | 'yearly') || 'monthly',
monthly_price: currentPlan?.monthly_price || 0,
yearly_price: currentPlan?.yearly_price || 0,
renewal_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // TODO: Get from actual subscription
renewal_date: status.next_billing_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
limits: {
users: currentPlan?.limits?.users ?? null,
locations: currentPlan?.limits?.locations ?? null,

View File

@@ -78,6 +78,14 @@ export class TenantService {
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/my-access`);
}
// ===================================================================
// OPERATIONS: Enterprise Hierarchy
// Backend: services/tenant/app/api/tenant_hierarchy.py
// ===================================================================
async getChildTenants(parentTenantId: string): Promise<TenantResponse[]> {
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/${parentTenantId}/children`);
}
// ===================================================================
// OPERATIONS: Search & Discovery
// Backend: services/tenant/app/api/tenant_operations.py

View File

@@ -1,265 +0,0 @@
/**
* Alert Processor service TypeScript type definitions
* Mirrored from backend alert processing schemas
*/
// Enums
export enum AlertItemType {
ALERT = 'alert',
RECOMMENDATION = 'recommendation',
}
export enum AlertType {
INVENTORY_LOW = 'inventory_low',
QUALITY_ISSUE = 'quality_issue',
DELIVERY_DELAY = 'delivery_delay',
PRODUCTION_DELAY = 'production_delay',
EQUIPMENT_FAILURE = 'equipment_failure',
FOOD_SAFETY = 'food_safety',
TEMPERATURE_ALERT = 'temperature_alert',
EXPIRY_WARNING = 'expiry_warning',
FORECAST_ACCURACY = 'forecast_accuracy',
DEMAND_SPIKE = 'demand_spike',
SUPPLIER_ISSUE = 'supplier_issue',
COST_OPTIMIZATION = 'cost_optimization',
REVENUE_OPPORTUNITY = 'revenue_opportunity',
}
export enum AlertSeverity {
URGENT = 'urgent',
HIGH = 'high',
MEDIUM = 'medium',
LOW = 'low',
}
export enum AlertService {
INVENTORY = 'inventory',
PRODUCTION = 'production',
SUPPLIERS = 'suppliers',
FORECASTING = 'forecasting',
QUALITY = 'quality',
FINANCE = 'finance',
OPERATIONS = 'operations',
}
export enum NotificationChannel {
WHATSAPP = 'whatsapp',
EMAIL = 'email',
PUSH = 'push',
DASHBOARD = 'dashboard',
SMS = 'sms',
}
// Core alert data structures
export interface AlertAction {
action: string;
label: string;
endpoint?: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
payload?: Record<string, any>;
}
export interface AlertMessage {
id: string;
tenant_id: string;
item_type: AlertItemType;
type: AlertType;
severity: AlertSeverity;
service: AlertService;
title: string;
message: string;
actions: AlertAction[];
metadata: Record<string, any>;
timestamp: string; // ISO 8601 date string
}
// Channel routing configuration
export interface ChannelRoutingConfig {
urgent: NotificationChannel[];
high: NotificationChannel[];
medium: NotificationChannel[];
low: NotificationChannel[];
recommendations: NotificationChannel[];
}
export interface BusinessHours {
start_hour: number; // 0-23
end_hour: number; // 0-23
days: number[]; // 0-6, Sunday=0
timezone?: string; // e.g., 'Europe/Madrid'
}
export interface NotificationSettings {
tenant_id: string;
channels_enabled: NotificationChannel[];
business_hours: BusinessHours;
emergency_contacts: {
whatsapp?: string;
email?: string;
sms?: string;
};
channel_preferences: {
[key in AlertSeverity]?: NotificationChannel[];
};
}
// Processing status and metrics
export interface ProcessingMetrics {
total_processed: number;
successful: number;
failed: number;
retries: number;
average_processing_time_ms: number;
}
export interface AlertProcessingStatus {
alert_id: string;
tenant_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed' | 'retrying';
created_at: string;
processed_at?: string;
error_message?: string;
retry_count: number;
channels_sent: NotificationChannel[];
delivery_status: {
[channel in NotificationChannel]?: {
status: 'pending' | 'sent' | 'delivered' | 'failed';
sent_at?: string;
delivered_at?: string;
error?: string;
};
};
}
// Queue message structures (for RabbitMQ integration)
export interface QueueMessage {
id: string;
routing_key: string;
exchange: string;
payload: AlertMessage;
headers?: Record<string, any>;
properties?: {
delivery_mode?: number;
priority?: number;
correlation_id?: string;
reply_to?: string;
expiration?: string;
message_id?: string;
timestamp?: number;
type?: string;
user_id?: string;
app_id?: string;
};
}
// SSE (Server-Sent Events) message types for real-time updates
export interface SSEAlertMessage {
type: 'alert' | 'recommendation' | 'alert_update' | 'system_status';
data: AlertMessage | AlertProcessingStatus | SystemStatusMessage;
timestamp: string;
}
export interface SystemStatusMessage {
service: 'alert_processor';
status: 'healthy' | 'degraded' | 'down';
message?: string;
metrics: ProcessingMetrics;
}
// Dashboard integration types
export interface AlertDashboardData {
active_alerts: AlertMessage[];
recent_recommendations: AlertMessage[];
severity_counts: {
[key in AlertSeverity]: number;
};
service_breakdown: {
[key in AlertService]: number;
};
processing_stats: ProcessingMetrics;
}
export interface AlertFilters {
severity?: AlertSeverity[];
type?: AlertType[];
service?: AlertService[];
item_type?: AlertItemType[];
date_from?: string; // ISO 8601 date string
date_to?: string; // ISO 8601 date string
status?: 'active' | 'acknowledged' | 'resolved' | 'dismissed';
search?: string;
}
export interface AlertQueryParams extends AlertFilters {
limit?: number;
offset?: number;
sort_by?: 'timestamp' | 'severity' | 'type';
sort_order?: 'asc' | 'desc';
}
// Alert lifecycle management
export interface AlertUpdateRequest {
status?: 'acknowledged' | 'resolved' | 'dismissed';
notes?: string;
assigned_to?: string;
priority_override?: AlertSeverity;
}
export interface AlertResponse extends AlertMessage {
status: 'active' | 'acknowledged' | 'resolved' | 'dismissed';
created_at: string;
updated_at?: string;
acknowledged_at?: string;
acknowledged_by?: string;
resolved_at?: string;
resolved_by?: string;
notes?: string;
assigned_to?: string;
}
// Webhook integration for external systems
export interface WebhookConfig {
tenant_id: string;
webhook_url: string;
secret_token: string;
enabled: boolean;
event_types: (AlertType | 'all')[];
severity_filter: AlertSeverity[];
headers?: Record<string, string>;
retry_config: {
max_retries: number;
retry_delay_ms: number;
backoff_multiplier: number;
};
}
export interface WebhookPayload {
event_type: 'alert_created' | 'alert_updated' | 'alert_resolved';
alert: AlertResponse;
tenant_id: string;
webhook_id: string;
timestamp: string;
signature: string; // HMAC signature for verification
}
// API Response Wrappers
export interface PaginatedResponse<T> {
data: T[];
total: number;
limit: number;
offset: number;
has_next: boolean;
has_previous: boolean;
}
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
errors?: string[];
}
// Export all types
export type {
// Add any additional export aliases if needed
};

View File

@@ -0,0 +1,457 @@
/**
* Unified Event Type System - Single Source of Truth
*
* Complete rewrite matching backend response structure exactly.
* NO backward compatibility, NO legacy fields.
*
* Backend files this mirrors:
* - /services/alert_processor/app/models/events_clean.py
* - /services/alert_processor/app/models/response_models_clean.py
*/
// ============================================================
// ENUMS - Matching Backend Exactly
// ============================================================
export enum EventClass {
ALERT = 'alert',
NOTIFICATION = 'notification',
RECOMMENDATION = 'recommendation',
}
export enum AlertTypeClass {
ACTION_NEEDED = 'action_needed',
PREVENTED_ISSUE = 'prevented_issue',
TREND_WARNING = 'trend_warning',
ESCALATION = 'escalation',
INFORMATION = 'information',
}
export enum PriorityLevel {
CRITICAL = 'critical', // 90-100
IMPORTANT = 'important', // 70-89
STANDARD = 'standard', // 50-69
INFO = 'info', // 0-49
}
export enum AlertStatus {
ACTIVE = 'active',
RESOLVED = 'resolved',
ACKNOWLEDGED = 'acknowledged',
IN_PROGRESS = 'in_progress',
DISMISSED = 'dismissed',
}
export enum SmartActionType {
APPROVE_PO = 'approve_po',
REJECT_PO = 'reject_po',
MODIFY_PO = 'modify_po',
VIEW_PO_DETAILS = 'view_po_details',
CALL_SUPPLIER = 'call_supplier',
NAVIGATE = 'navigate',
ADJUST_PRODUCTION = 'adjust_production',
START_PRODUCTION_BATCH = 'start_production_batch',
NOTIFY_CUSTOMER = 'notify_customer',
CANCEL_AUTO_ACTION = 'cancel_auto_action',
MARK_DELIVERY_RECEIVED = 'mark_delivery_received',
COMPLETE_STOCK_RECEIPT = 'complete_stock_receipt',
OPEN_REASONING = 'open_reasoning',
SNOOZE = 'snooze',
DISMISS = 'dismiss',
MARK_READ = 'mark_read',
}
export enum NotificationType {
STATE_CHANGE = 'state_change',
COMPLETION = 'completion',
ARRIVAL = 'arrival',
DEPARTURE = 'departure',
UPDATE = 'update',
SYSTEM_EVENT = 'system_event',
}
export enum RecommendationType {
OPTIMIZATION = 'optimization',
COST_REDUCTION = 'cost_reduction',
RISK_MITIGATION = 'risk_mitigation',
TREND_INSIGHT = 'trend_insight',
BEST_PRACTICE = 'best_practice',
}
// ============================================================
// CONTEXT INTERFACES - Matching Backend Response Models
// ============================================================
/**
* i18n display context with parameterized content
* Backend field name: "i18n" (NOT "display")
*/
export interface I18nDisplayContext {
title_key: string;
message_key: string;
title_params: Record<string, any>;
message_params: Record<string, any>;
}
export interface BusinessImpactContext {
financial_impact_eur?: number;
waste_prevented_eur?: number;
time_saved_minutes?: number;
production_loss_avoided_eur?: number;
potential_loss_eur?: number;
}
/**
* Urgency context
* Backend field name: "urgency" (NOT "urgency_context")
*/
export interface UrgencyContext {
deadline_utc?: string; // ISO date string
hours_until_consequence?: number;
auto_action_countdown_seconds?: number;
auto_action_cancelled?: boolean;
urgency_reason_key?: string; // i18n key
urgency_reason_params?: Record<string, any>;
priority: string; // "critical", "urgent", "normal", "info"
}
export interface UserAgencyContext {
action_required: boolean;
external_party_required?: boolean;
external_party_name?: string;
external_party_contact?: string;
estimated_resolution_time_minutes?: number;
user_control_level: string; // "full", "partial", "none"
action_urgency: string; // "immediate", "soon", "normal"
}
export interface TrendContext {
metric_name: string;
current_value: number;
baseline_value: number;
change_percentage: number;
direction: 'increasing' | 'decreasing';
significance: 'high' | 'medium' | 'low';
period_days: number;
possible_causes?: string[];
}
/**
* Smart action with parameterized i18n labels
* Backend field name in Alert: "smart_actions" (NOT "actions")
*/
export interface SmartAction {
action_type: string;
label_key: string; // i18n key for button text
label_params?: Record<string, any>;
variant: 'primary' | 'secondary' | 'danger' | 'ghost';
disabled: boolean;
consequence_key?: string; // i18n key for consequence text
consequence_params?: Record<string, any>;
disabled_reason?: string;
disabled_reason_key?: string; // i18n key for disabled reason
disabled_reason_params?: Record<string, any>;
estimated_time_minutes?: number;
metadata: Record<string, any>;
}
export interface AIReasoningContext {
summary_key?: string; // i18n key
summary_params?: Record<string, any>;
details?: Record<string, any>;
}
// ============================================================
// EVENT RESPONSE TYPES - Base and Specific Types
// ============================================================
/**
* Base Event interface with common fields
*/
export interface Event {
// Core Identity
id: string;
tenant_id: string;
event_class: EventClass;
event_domain: string;
event_type: string;
service: string;
// i18n Display Context
// CRITICAL: Backend uses "i18n", NOT "display"
i18n: I18nDisplayContext;
// Classification
priority_level: PriorityLevel;
status: string;
// Timestamps
created_at: string; // ISO date string
updated_at: string; // ISO date string
// Optional context fields
event_metadata?: Record<string, any>;
}
/**
* Alert - Full enrichment, lifecycle tracking
*/
export interface Alert extends Event {
event_class: EventClass.ALERT;
status: AlertStatus | string;
// Alert-specific classification
type_class: AlertTypeClass;
priority_score: number; // 0-100
// Rich Context
// CRITICAL: Backend uses "urgency", NOT "urgency_context"
business_impact?: BusinessImpactContext;
urgency?: UrgencyContext;
user_agency?: UserAgencyContext;
trend_context?: TrendContext;
orchestrator_context?: Record<string, any>;
// AI Intelligence
ai_reasoning?: AIReasoningContext;
confidence_score: number;
// Actions
// CRITICAL: Backend uses "smart_actions", NOT "actions"
smart_actions: SmartAction[];
// Entity References
// CRITICAL: Backend uses "entity_links", NOT "entity_refs"
entity_links: Record<string, string>;
// Timing Intelligence
timing_decision?: string;
scheduled_send_time?: string; // ISO date string
// Placement
placement_hints?: string[];
// Escalation & Chaining
action_created_at?: string; // ISO date string
superseded_by_action_id?: string;
hidden_from_ui?: boolean;
// Lifecycle
resolved_at?: string; // ISO date string
acknowledged_at?: string; // ISO date string
acknowledged_by?: string;
resolved_by?: string;
notes?: string;
assigned_to?: string;
}
/**
* Notification - Lightweight, ephemeral (7-day TTL)
*/
export interface Notification extends Event {
event_class: EventClass.NOTIFICATION;
// Notification-specific
notification_type: NotificationType;
// Entity Context (lightweight)
entity_type?: string; // 'batch', 'delivery', 'po', etc.
entity_id?: string;
old_state?: string;
new_state?: string;
// Placement
placement_hints?: string[];
// TTL
expires_at?: string; // ISO date string
}
/**
* Recommendation - Medium weight, dismissible
*/
export interface Recommendation extends Event {
event_class: EventClass.RECOMMENDATION;
// Recommendation-specific
recommendation_type: RecommendationType;
// Context (lighter than alerts)
estimated_impact?: Record<string, any>;
suggested_actions?: SmartAction[];
// AI Intelligence
ai_reasoning?: AIReasoningContext;
confidence_score?: number;
// Dismissal
dismissed_at?: string; // ISO date string
dismissed_by?: string;
}
/**
* Union type for all event responses
*/
export type EventResponse = Alert | Notification | Recommendation;
// ============================================================
// API RESPONSE WRAPPERS
// ============================================================
export interface PaginatedResponse<T> {
items: T[];
total: number;
limit: number;
offset: number;
has_next: boolean;
has_previous: boolean;
}
export interface EventsSummary {
total_count: number;
active_count: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
resolved_count: number;
acknowledged_count: number;
}
export interface EventQueryParams {
priority_level?: PriorityLevel | string;
status?: AlertStatus | string;
resolved?: boolean;
event_class?: EventClass | string;
event_domain?: string;
limit?: number;
offset?: number;
}
// ============================================================
// TYPE GUARDS
// ============================================================
export function isAlert(event: EventResponse | Event): event is Alert {
return event.event_class === EventClass.ALERT || event.event_class === 'alert';
}
export function isNotification(event: EventResponse | Event): event is Notification {
return event.event_class === EventClass.NOTIFICATION || event.event_class === 'notification';
}
export function isRecommendation(event: EventResponse | Event): event is Recommendation {
return event.event_class === EventClass.RECOMMENDATION || event.event_class === 'recommendation';
}
// ============================================================
// HELPER FUNCTIONS
// ============================================================
export function getPriorityColor(level: PriorityLevel | string): string {
switch (level) {
case PriorityLevel.CRITICAL:
case 'critical':
return 'var(--color-error)';
case PriorityLevel.IMPORTANT:
case 'important':
return 'var(--color-warning)';
case PriorityLevel.STANDARD:
case 'standard':
return 'var(--color-info)';
case PriorityLevel.INFO:
case 'info':
return 'var(--color-success)';
default:
return 'var(--color-info)';
}
}
export function getPriorityIcon(level: PriorityLevel | string): string {
switch (level) {
case PriorityLevel.CRITICAL:
case 'critical':
return 'alert-triangle';
case PriorityLevel.IMPORTANT:
case 'important':
return 'alert-circle';
case PriorityLevel.STANDARD:
case 'standard':
return 'info';
case PriorityLevel.INFO:
case 'info':
return 'check-circle';
default:
return 'info';
}
}
export function getTypeClassBadgeVariant(
typeClass: AlertTypeClass | string
): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline' {
switch (typeClass) {
case AlertTypeClass.ACTION_NEEDED:
case 'action_needed':
return 'error';
case AlertTypeClass.PREVENTED_ISSUE:
case 'prevented_issue':
return 'success';
case AlertTypeClass.TREND_WARNING:
case 'trend_warning':
return 'warning';
case AlertTypeClass.ESCALATION:
case 'escalation':
return 'error';
case AlertTypeClass.INFORMATION:
case 'information':
return 'info';
default:
return 'default';
}
}
export function formatTimeUntilConsequence(hours?: number): string {
if (!hours) return '';
if (hours < 1) {
return `${Math.round(hours * 60)} minutes`;
} else if (hours < 24) {
return `${Math.round(hours)} hours`;
} else {
return `${Math.round(hours / 24)} days`;
}
}
/**
* Convert legacy alert format to new Event format
* This function provides backward compatibility for older alert structures
*/
export function convertLegacyAlert(legacyAlert: any): Event {
// If it's already in the new format, return as-is
if (legacyAlert.event_class && legacyAlert.event_class in EventClass) {
return legacyAlert;
}
// Convert legacy format to new format
const newAlert: Event = {
id: legacyAlert.id || legacyAlert.alert_id || '',
tenant_id: legacyAlert.tenant_id || '',
event_class: EventClass.ALERT, // Default to alert
event_domain: legacyAlert.event_domain || '',
event_type: legacyAlert.event_type || legacyAlert.type || '',
service: legacyAlert.service || 'unknown',
i18n: legacyAlert.i18n || {
title_key: legacyAlert.title_key || legacyAlert.title || '',
message_key: legacyAlert.message_key || legacyAlert.message || '',
title_params: legacyAlert.title_params || {},
message_params: legacyAlert.message_params || {},
},
priority_level: legacyAlert.priority_level || PriorityLevel.STANDARD,
status: legacyAlert.status || 'active',
created_at: legacyAlert.created_at || new Date().toISOString(),
updated_at: legacyAlert.updated_at || new Date().toISOString(),
event_metadata: legacyAlert.event_metadata || legacyAlert.metadata || {},
};
return newAlert;
}

View File

@@ -0,0 +1,117 @@
/**
* Orchestrator API Types
*/
export interface OrchestratorWorkflowRequest {
target_date?: string; // YYYY-MM-DD, defaults to tomorrow
planning_horizon_days?: number; // Default: 14
// Forecasting options
forecast_days_ahead?: number; // Default: 7
// Production options
auto_schedule_production?: boolean; // Default: true
production_planning_days?: number; // Default: 1
// Procurement options
auto_create_purchase_orders?: boolean; // Default: true
auto_approve_purchase_orders?: boolean; // Default: false
safety_stock_percentage?: number; // Default: 20.00
// Orchestrator options
skip_on_error?: boolean; // Continue to next step if one fails
notify_on_completion?: boolean; // Send notification when done
}
export interface WorkflowStepResult {
step: 'forecasting' | 'production' | 'procurement';
status: 'success' | 'failed' | 'skipped';
duration_ms: number;
data?: any;
error?: string;
warnings?: string[];
}
export interface OrchestratorWorkflowResponse {
success: boolean;
workflow_id: string;
tenant_id: string;
target_date: string;
execution_date: string;
total_duration_ms: number;
steps: WorkflowStepResult[];
// Step-specific results
forecast_result?: {
forecast_id: string;
total_forecasts: number;
forecast_data: any;
};
production_result?: {
schedule_id: string;
total_batches: number;
total_quantity: number;
};
procurement_result?: {
plan_id: string;
total_requirements: number;
total_cost: string;
purchase_orders_created: number;
purchase_orders_auto_approved: number;
};
warnings?: string[];
errors?: string[];
}
export interface WorkflowExecutionSummary {
id: string;
tenant_id: string;
target_date: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
started_at: string;
completed_at?: string;
total_duration_ms?: number;
steps_completed: number;
steps_total: number;
created_by?: string;
}
export interface WorkflowExecutionDetail extends WorkflowExecutionSummary {
steps: WorkflowStepResult[];
forecast_id?: string;
production_schedule_id?: string;
procurement_plan_id?: string;
warnings?: string[];
errors?: string[];
}
export interface OrchestratorStatus {
is_leader: boolean;
scheduler_running: boolean;
next_scheduled_run?: string;
last_execution?: {
id: string;
target_date: string;
status: string;
completed_at: string;
};
total_executions_today: number;
total_successful_executions: number;
total_failed_executions: number;
}
export interface OrchestratorConfig {
enabled: boolean;
schedule_cron: string; // Cron expression for daily run
default_planning_horizon_days: number;
auto_create_purchase_orders: boolean;
auto_approve_purchase_orders: boolean;
safety_stock_percentage: number;
notify_on_completion: boolean;
notify_on_failure: boolean;
skip_on_error: boolean;
}

View File

@@ -1,32 +1,33 @@
/*
* Performance Chart Component for Enterprise Dashboard
* Shows anonymized performance ranking of child outlets
* Shows performance ranking of child outlets with clickable names
*/
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card';
import { Badge } from '../ui/Badge';
import { BarChart3, TrendingUp, TrendingDown, ArrowUp, ArrowDown } from 'lucide-react';
import { BarChart3, TrendingUp, TrendingDown, ArrowUp, ArrowDown, ExternalLink, Package, ShoppingCart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface PerformanceDataPoint {
rank: number;
tenant_id: string;
anonymized_name: string; // "Outlet 1", "Outlet 2", etc.
outlet_name: string;
metric_value: number;
original_name?: string; // Only for internal use, not displayed
}
interface PerformanceChartProps {
data: PerformanceDataPoint[];
metric: string;
period: number;
onOutletClick?: (tenantId: string, outletName: string) => void;
}
const PerformanceChart: React.FC<PerformanceChartProps> = ({
data = [],
metric,
period
const PerformanceChart: React.FC<PerformanceChartProps> = ({
data = [],
metric,
period,
onOutletClick
}) => {
const { t } = useTranslation('dashboard');
@@ -94,14 +95,31 @@ const PerformanceChart: React.FC<PerformanceChartProps> = ({
<div key={item.tenant_id} className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
isTopPerformer
? 'bg-yellow-100 text-yellow-800 border border-yellow-300'
: 'bg-gray-100 text-gray-700'
}`}>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium"
style={isTopPerformer ? {
backgroundColor: 'var(--color-warning-light, #fef3c7)',
color: 'var(--color-warning-dark, #92400e)',
borderColor: 'var(--color-warning, #fbbf24)',
borderWidth: '1px',
borderStyle: 'solid'
} : {
backgroundColor: 'var(--bg-tertiary, #f1f5f9)',
color: 'var(--text-secondary, #475569)'
}}>
{item.rank}
</div>
<span className="font-medium">{item.anonymized_name}</span>
{onOutletClick ? (
<button
onClick={() => onOutletClick(item.tenant_id, item.outlet_name)}
className="font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] hover:underline flex items-center gap-1.5 transition-colors"
>
{item.outlet_name}
<ExternalLink className="w-3.5 h-3.5" />
</button>
) : (
<span className="font-medium">{item.outlet_name}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">
@@ -114,15 +132,27 @@ const PerformanceChart: React.FC<PerformanceChartProps> = ({
)}
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div className="w-full rounded-full h-3" style={{ backgroundColor: 'var(--bg-quaternary)' }}>
<div
className={`h-3 rounded-full transition-all duration-500 ${
isTopPerformer
? 'bg-gradient-to-r from-blue-500 to-purple-500'
: 'bg-blue-400'
}`}
style={{ width: `${percentage}%` }}
></div>
className="h-3 rounded-full transition-all duration-500 relative overflow-hidden"
style={{
width: `${percentage}%`,
background: isTopPerformer
? 'linear-gradient(90deg, var(--chart-secondary) 0%, var(--chart-primary) 100%)'
: 'var(--chart-secondary)'
}}
>
{/* Shimmer effect for top performer */}
{isTopPerformer && (
<div
className="absolute inset-0 animate-shimmer"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
backgroundSize: '200% 100%',
}}
/>
)}
</div>
</div>
</div>
);

View File

@@ -53,12 +53,34 @@ export interface ProductionProgress {
};
}
export interface DeliveryInfo {
poId: string;
poNumber: string;
supplierName: string;
supplierPhone?: string;
expectedDeliveryDate: string;
status: string;
lineItems: Array<{
product_name: string;
quantity: number;
unit: string;
}>;
totalAmount: number;
currency: string;
itemCount: number;
hoursOverdue?: number;
hoursUntil?: number;
}
export interface DeliveryProgress {
status: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk';
total: number;
received: number;
pending: number;
overdue: number;
overdueDeliveries?: DeliveryInfo[];
pendingDeliveries?: DeliveryInfo[];
receivedDeliveries?: DeliveryInfo[];
}
export interface ApprovalProgress {
@@ -356,43 +378,141 @@ export function ExecutionProgressTracker({
{t('dashboard:execution_progress.no_deliveries_today')}
</p>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<CheckCircle className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
<>
{/* Summary Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<CheckCircle className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-700)' }}>
{progress.deliveries.received}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.received')}
</div>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-700)' }}>
{progress.deliveries.received}
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<Clock className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-700)' }}>
{progress.deliveries.pending}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.pending')}
</div>
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.received')}
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-error-700)' }}>
{progress.deliveries.overdue}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.overdue')}
</div>
</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<Clock className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
{/* Overdue Deliveries List */}
{progress.deliveries.overdueDeliveries && progress.deliveries.overdueDeliveries.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-error-700)' }}>
<AlertCircle className="w-3 h-3" />
{t('dashboard:execution_progress.overdue_deliveries')}
</div>
<div className="space-y-2">
{progress.deliveries.overdueDeliveries.map((delivery) => (
<div
key={delivery.poId}
className="p-3 rounded-lg border"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
}}
>
<div className="flex items-start justify-between mb-1">
<div>
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{delivery.supplierName}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{delivery.poNumber} · {delivery.hoursOverdue}h {t('dashboard:execution_progress.overdue_label')}
</div>
</div>
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{delivery.totalAmount.toFixed(2)} {delivery.currency}
</div>
</div>
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
{delivery.lineItems.slice(0, 2).map((item, idx) => (
<div key={idx}> {item.product_name} ({item.quantity} {item.unit})</div>
))}
{delivery.itemCount > 2 && (
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-700)' }}>
{progress.deliveries.pending}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.pending')}
</div>
</div>
)}
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
{/* Pending Deliveries List */}
{progress.deliveries.pendingDeliveries && progress.deliveries.pendingDeliveries.length > 0 && (
<div>
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-info-700)' }}>
<Clock className="w-3 h-3" />
{t('dashboard:execution_progress.pending_deliveries')}
</div>
<div className="space-y-2">
{progress.deliveries.pendingDeliveries.slice(0, 3).map((delivery) => (
<div
key={delivery.poId}
className="p-3 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-secondary)',
}}
>
<div className="flex items-start justify-between mb-1">
<div>
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{delivery.supplierName}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{delivery.poNumber} · {delivery.hoursUntil !== undefined && delivery.hoursUntil >= 0
? `${t('dashboard:execution_progress.arriving_in')} ${delivery.hoursUntil}h`
: formatTime(delivery.expectedDeliveryDate)}
</div>
</div>
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{delivery.totalAmount.toFixed(2)} {delivery.currency}
</div>
</div>
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
{delivery.lineItems.slice(0, 2).map((item, idx) => (
<div key={idx}> {item.product_name} ({item.quantity} {item.unit})</div>
))}
{delivery.itemCount > 2 && (
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
)}
</div>
</div>
))}
{progress.deliveries.pendingDeliveries.length > 3 && (
<div className="text-xs text-center py-1" style={{ color: 'var(--text-tertiary)' }}>
+ {progress.deliveries.pendingDeliveries.length - 3} {t('dashboard:execution_progress.more_deliveries')}
</div>
)}
</div>
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-error-700)' }}>
{progress.deliveries.overdue}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.overdue')}
</div>
</div>
</div>
)}
</>
)}
</Section>

View File

@@ -17,12 +17,12 @@
import React, { useState, useMemo } from 'react';
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw, Zap, ChevronDown, ChevronUp, ChevronRight } from 'lucide-react';
import { BakeryHealthStatus } from '../../api/hooks/newDashboard';
import { BakeryHealthStatus } from '../../api/hooks/useProfessionalDashboard';
import { formatDistanceToNow } from 'date-fns';
import { es, eu, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNotifications } from '../../hooks/useNotifications';
import { useEventNotifications } from '../../hooks/useEventNotifications';
interface GlanceableHealthHeroProps {
healthStatus: BakeryHealthStatus;
@@ -104,7 +104,7 @@ function translateKey(
export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount = 0 }: GlanceableHealthHeroProps) {
const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']);
const navigate = useNavigate();
const { notifications } = useNotifications();
const { notifications } = useEventNotifications();
const [detailsExpanded, setDetailsExpanded] = useState(false);
// Get date-fns locale
@@ -119,12 +119,23 @@ export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount
const criticalAlerts = useMemo(() => {
if (!notifications || notifications.length === 0) return [];
return notifications.filter(
n => n.priority_level === 'CRITICAL' && !n.read && n.type_class !== 'prevented_issue'
n => n.priority_level === 'critical' && !n.read && n.type_class !== 'prevented_issue'
);
}, [notifications]);
const criticalAlertsCount = criticalAlerts.length;
// Filter prevented issues from last 7 days to match IntelligentSystemSummaryCard
const preventedIssuesCount = useMemo(() => {
if (!notifications || notifications.length === 0) return 0;
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return notifications.filter(
n => n.type_class === 'prevented_issue' && new Date(n.timestamp) >= sevenDaysAgo
).length;
}, [notifications]);
// Create stable key for checklist items to prevent infinite re-renders
const checklistItemsKey = useMemo(() => {
if (!healthStatus?.checklistItems || healthStatus.checklistItems.length === 0) return 'empty';
@@ -237,11 +248,11 @@ export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount
</div>
)}
{/* AI Prevented Badge */}
{healthStatus.aiPreventedIssues && healthStatus.aiPreventedIssues > 0 && (
{/* AI Prevented Badge - Show last 7 days to match detail section */}
{preventedIssuesCount > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-info-100)', color: 'var(--color-info-800)' }}>
<Zap className="w-4 h-4" />
<span className="font-semibold">{healthStatus.aiPreventedIssues} evitado{healthStatus.aiPreventedIssues > 1 ? 's' : ''}</span>
<span className="font-semibold">{preventedIssuesCount} evitado{preventedIssuesCount > 1 ? 's' : ''}</span>
</div>
)}
</div>

View File

@@ -22,13 +22,15 @@ import {
Zap,
ShieldCheck,
Euro,
Package,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/newDashboard';
import { OrchestrationSummary } from '../../api/hooks/useProfessionalDashboard';
import { useTranslation } from 'react-i18next';
import { formatTime, formatRelativeTime } from '../../utils/date';
import { useTenant } from '../../stores/tenant.store';
import { useNotifications } from '../../hooks/useNotifications';
import { EnrichedAlert } from '../../types/alerts';
import { useEventNotifications } from '../../hooks/useEventNotifications';
import { Alert } from '../../api/types/events';
import { renderEventTitle, renderEventMessage } from '../../utils/i18n/alertRendering';
import { Badge } from '../ui/Badge';
interface PeriodComparison {
@@ -77,10 +79,10 @@ export function IntelligentSystemSummaryCard({
}: IntelligentSystemSummaryCardProps) {
const { t } = useTranslation(['dashboard', 'reasoning']);
const { currentTenant } = useTenant();
const { notifications } = useNotifications();
const { notifications } = useEventNotifications();
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [preventedAlerts, setPreventedAlerts] = useState<EnrichedAlert[]>([]);
const [preventedAlerts, setPreventedAlerts] = useState<Alert[]>([]);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
const [preventedIssuesExpanded, setPreventedIssuesExpanded] = useState(false);
const [orchestrationExpanded, setOrchestrationExpanded] = useState(false);
@@ -102,7 +104,7 @@ export function IntelligentSystemSummaryCard({
`/tenants/${currentTenant.id}/alerts/analytics/dashboard`,
{ params: { days: 30 } }
),
apiClient.get<{ alerts: EnrichedAlert[] }>(
apiClient.get<{ alerts: Alert[] }>(
`/tenants/${currentTenant.id}/alerts`,
{ params: { limit: 100 } }
),
@@ -132,15 +134,29 @@ export function IntelligentSystemSummaryCard({
fetchAnalytics();
}, [currentTenant?.id]);
// Real-time prevented issues from SSE
const preventedIssuesKey = useMemo(() => {
if (!notifications || notifications.length === 0) return 'empty';
return notifications
.filter((n) => n.type_class === 'prevented_issue' && !n.read)
.map((n) => n.id)
.sort()
.join(',');
}, [notifications]);
// Real-time prevented issues from SSE - merge with API data
const allPreventedAlerts = useMemo(() => {
if (!notifications || notifications.length === 0) return preventedAlerts;
// Filter SSE notifications for prevented issues from last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const ssePreventedIssues = notifications.filter(
(n) => n.type_class === 'prevented_issue' && new Date(n.created_at) >= sevenDaysAgo
);
// Deduplicate: combine SSE + API data, removing duplicates by ID
const existingIds = new Set(preventedAlerts.map((a) => a.id));
const newSSEAlerts = ssePreventedIssues.filter((n) => !existingIds.has(n.id));
// Merge and sort by created_at (newest first)
const merged = [...preventedAlerts, ...newSSEAlerts].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
return merged.slice(0, 20); // Keep only top 20
}, [preventedAlerts, notifications]);
// Calculate metrics
const totalSavings = analytics?.estimated_savings_eur || 0;
@@ -214,14 +230,14 @@ export function IntelligentSystemSummaryCard({
)}
</div>
{/* Prevented Issues Badge */}
{/* Prevented Issues Badge - Show actual count from last 7 days to match detail section */}
<div
className="flex items-center gap-1 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-primary-100)', border: '1px solid var(--color-primary-300)' }}
>
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-primary-700)' }}>
{analytics?.prevented_issues_count || 0}
{allPreventedAlerts.length}
</span>
<span className="text-xs" style={{ color: 'var(--color-primary-600)' }}>
{t('dashboard:intelligent_system.prevented_issues', 'issues')}
@@ -261,7 +277,7 @@ export function IntelligentSystemSummaryCard({
{/* Collapsible Section: Prevented Issues Details */}
{preventedIssuesExpanded && (
<div className="px-6 pb-6 pt-2 border-t" style={{ borderColor: 'var(--color-success-200)' }}>
{preventedAlerts.length === 0 ? (
{allPreventedAlerts.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:intelligent_system.no_prevented_issues', 'No issues prevented this week - all systems running smoothly!')}
@@ -276,14 +292,14 @@ export function IntelligentSystemSummaryCard({
>
<p className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{t('dashboard:intelligent_system.celebration', 'Great news! AI prevented {count} issue(s) before they became problems.', {
count: preventedAlerts.length,
count: allPreventedAlerts.length,
})}
</p>
</div>
{/* Prevented Issues List */}
<div className="space-y-2">
{preventedAlerts.map((alert) => {
{allPreventedAlerts.map((alert) => {
const savings = alert.orchestrator_context?.estimated_savings_eur || 0;
const actionTaken = alert.orchestrator_context?.action_taken || 'AI intervention';
const timeAgo = formatRelativeTime(alert.created_at) || 'Fecha desconocida';
@@ -299,10 +315,10 @@ export function IntelligentSystemSummaryCard({
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-success-600)' }} />
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1" style={{ color: 'var(--text-primary)' }}>
{alert.title}
{renderEventTitle(alert, t)}
</h4>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{alert.message}
{renderEventMessage(alert, t)}
</p>
</div>
</div>
@@ -418,6 +434,87 @@ export function IntelligentSystemSummaryCard({
</span>
)}
</div>
{/* AI Reasoning Section */}
{orchestrationSummary.reasoning && orchestrationSummary.reasoning.reasoning_i18n && (
<div className="mt-4 space-y-3">
{/* Reasoning Text Block */}
<div
className="rounded-lg p-4 border-l-4"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--color-info-600)',
}}
>
<div className="flex items-start gap-2 mb-2">
<Bot className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
<h4 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('alerts:orchestration.reasoning_title', '🤖 Razonamiento del Orquestador Diario')}
</h4>
</div>
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
{t(
orchestrationSummary.reasoning.reasoning_i18n.key,
orchestrationSummary.reasoning.reasoning_i18n.params || {}
)}
</p>
</div>
{/* Business Impact Metrics */}
{(orchestrationSummary.reasoning.business_impact?.financial_impact_eur ||
orchestrationSummary.reasoning.business_impact?.affected_orders) && (
<div className="flex flex-wrap gap-2">
{orchestrationSummary.reasoning.business_impact.financial_impact_eur > 0 && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md"
style={{
backgroundColor: 'var(--color-success-100)',
border: '1px solid var(--color-success-300)',
}}
>
<TrendingUp className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{orchestrationSummary.reasoning.business_impact.financial_impact_eur.toFixed(0)}{' '}
{t('dashboard:intelligent_system.estimated_savings', 'impacto financiero')}
</span>
</div>
)}
{orchestrationSummary.reasoning.business_impact.affected_orders > 0 && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md"
style={{
backgroundColor: 'var(--color-info-100)',
border: '1px solid var(--color-info-300)',
}}
>
<Package className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-info-700)' }}>
{orchestrationSummary.reasoning.business_impact.affected_orders}{' '}
{t('common:orders', 'pedidos')}
</span>
</div>
)}
</div>
)}
{/* Urgency Context */}
{orchestrationSummary.reasoning.urgency_context?.time_until_consequence_hours > 0 && (
<div
className="rounded-lg p-3 flex items-center gap-2"
style={{
backgroundColor: 'var(--color-warning-50)',
borderLeft: '4px solid var(--color-warning-600)',
}}
>
<Clock className="w-4 h-4" style={{ color: 'var(--color-warning-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-warning-800)' }}>
{Math.round(orchestrationSummary.reasoning.urgency_context.time_until_consequence_hours)}h{' '}
{t('common:remaining', 'restantes')}
</span>
</div>
)}
</div>
)}
</div>
) : (
<div className="text-sm text-center py-4" style={{ color: 'var(--text-secondary)' }}>

View File

@@ -36,12 +36,26 @@ import {
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { UnifiedActionQueue, EnrichedAlert } from '../../api/hooks/newDashboard';
import { Alert } from '../../api/types/events';
import { renderEventTitle, renderEventMessage, renderActionLabel, renderAIReasoning } from '../../utils/i18n/alertRendering';
import { useSmartActionHandler, mapActionVariantToButton } from '../../utils/smartActionHandlers';
import { Button } from '../ui/Button';
import { useNotifications } from '../../hooks/useNotifications';
import { useEventNotifications } from '../../hooks/useEventNotifications';
import { useQueryClient } from '@tanstack/react-query';
import { StockReceiptModal } from './StockReceiptModal';
import { ReasoningModal } from '../domain/dashboard/ReasoningModal';
import { UnifiedPurchaseOrderModal } from '../domain/procurement/UnifiedPurchaseOrderModal';
// Unified Action Queue interface (keeping for compatibility with dashboard hook)
interface UnifiedActionQueue {
urgent: Alert[];
today: Alert[];
week: Alert[];
urgentCount: number;
todayCount: number;
weekCount: number;
totalActions: number;
}
interface UnifiedActionQueueCardProps {
actionQueue: UnifiedActionQueue;
@@ -50,7 +64,7 @@ interface UnifiedActionQueueCardProps {
}
interface ActionCardProps {
alert: EnrichedAlert;
alert: Alert;
showEscalationBadge?: boolean;
onActionSuccess?: () => void;
onActionError?: (error: string) => void;
@@ -83,14 +97,14 @@ function getUrgencyColor(priorityLevel: string): {
}
}
function EscalationBadge({ alert }: { alert: EnrichedAlert }) {
function EscalationBadge({ alert }: { alert: Alert }) {
const { t } = useTranslation('alerts');
const escalation = alert.alert_metadata?.escalation;
const escalation = alert.event_metadata?.escalation;
if (!escalation || escalation.boost_applied === 0) return null;
const hoursPending = alert.urgency_context?.time_until_consequence_hours
? Math.round(alert.urgency_context.time_until_consequence_hours)
const hoursPending = alert.urgency?.hours_until_consequence
? Math.round(alert.urgency.hours_until_consequence)
: null;
return (
@@ -119,6 +133,10 @@ function getActionLabelKey(actionType: string, metadata?: Record<string, any>):
key: 'alerts:actions.reject_po',
extractParams: () => ({})
},
'view_po_details': {
key: 'alerts:actions.view_po_details',
extractParams: () => ({})
},
'call_supplier': {
key: 'alerts:actions.call_supplier',
extractParams: (meta) => ({ supplier: meta.supplier || meta.name || 'Supplier', phone: meta.phone || '' })
@@ -172,10 +190,10 @@ function getActionLabelKey(actionType: string, metadata?: Record<string, any>):
}
function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onActionError }: ActionCardProps) {
const [expanded, setExpanded] = useState(false);
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const [actionCompleted, setActionCompleted] = useState(false);
const { t } = useTranslation('alerts');
const [showReasoningModal, setShowReasoningModal] = useState(false);
const { t } = useTranslation(['alerts', 'reasoning']);
const colors = getUrgencyColor(alert.priority_level);
// Action handler with callbacks
@@ -198,17 +216,30 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
// Get icon based on alert type
const getAlertIcon = () => {
if (!alert.alert_type) return AlertCircle;
if (alert.alert_type.includes('delivery')) return Truck;
if (alert.alert_type.includes('production')) return Package;
if (alert.alert_type.includes('procurement') || alert.alert_type.includes('po')) return Calendar;
if (!alert.event_type) return AlertCircle;
if (alert.event_type.includes('delivery')) return Truck;
if (alert.event_type.includes('production')) return Package;
if (alert.event_type.includes('procurement') || alert.event_type.includes('po')) return Calendar;
return AlertCircle;
};
const AlertIcon = getAlertIcon();
// Get actions from alert
const alertActions = alert.actions || [];
// Get actions from alert, filter out "Ver razonamiento" since reasoning is now always visible
const alertActions = (alert.smart_actions || []).filter(action => action.action_type !== 'open_reasoning');
// Debug logging to diagnose action button issues (can be removed after verification)
if (alert.smart_actions && alert.smart_actions.length > 0 && alertActions.length === 0) {
console.warn('[ActionQueue] All actions filtered out for alert:', alert.id, alert.smart_actions);
}
if (alertActions.length > 0) {
console.debug('[ActionQueue] Rendering actions for alert:', alert.id, alertActions.map(a => ({
type: a.action_type,
hasMetadata: !!a.metadata,
hasAmount: a.metadata ? 'amount' in a.metadata : false,
metadata: a.metadata
})));
}
// Get icon for action type
const getActionIcon = (actionType: string) => {
@@ -219,7 +250,10 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
};
// Determine if this is a critical alert that needs stronger visual treatment
const isCritical = alert.priority_level === 'CRITICAL';
const isCritical = alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL';
// Extract reasoning from alert using new rendering utility
const reasoningText = renderAIReasoning(alert, t) || '';
return (
<div
@@ -239,12 +273,13 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
style={{ color: colors.border }}
/>
<div className="flex-1 min-w-0">
{/* Header with Title and Escalation Badge */}
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className={`font-bold ${isCritical ? 'text-xl' : 'text-lg'}`} style={{ color: colors.text }}>
{alert.title}
{renderEventTitle(alert, t)}
</h3>
{/* Only show escalation badge if applicable */}
{showEscalationBadge && alert.alert_metadata?.escalation && (
{showEscalationBadge && alert.event_metadata?.escalation && (
<div className="flex items-center gap-2 flex-shrink-0">
<span
className="px-2 py-1 rounded text-xs font-semibold uppercase"
@@ -262,117 +297,160 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{/* Escalation Badge Details */}
{showEscalationBadge && <EscalationBadge alert={alert} />}
{/* Message */}
<p className="text-sm mb-3 text-[var(--text-secondary)]">
{alert.message}
</p>
{/* Context Badges - Matching Health Hero Style */}
{(alert.business_impact || alert.urgency_context || alert.type_class === 'prevented_issue') && (
<div className="flex flex-wrap gap-2 mb-3">
{alert.business_impact?.financial_impact_eur && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-warning-100)] text-[var(--color-warning-800)]"
>
<TrendingUp className="w-4 h-4" />
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} at risk</span>
</div>
)}
{alert.urgency_context?.time_until_consequence_hours && (
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold ${
alert.urgency_context.time_until_consequence_hours < 6 ? 'animate-pulse' : ''
} bg-[var(--color-error-100)] text-[var(--color-error-800)]`}
>
<Clock className="w-4 h-4" />
<span>{Math.round(alert.urgency_context.time_until_consequence_hours)}h left</span>
</div>
)}
{alert.type_class === 'prevented_issue' && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<Bot className="w-4 h-4" />
<span>AI handled</span>
</div>
)}
{/* What/Why/How Structure - Enhanced with clear section labels */}
<div className="space-y-3">
{/* WHAT: What happened - The alert message */}
<div className="space-y-2">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{t('reasoning:jtbd.action_queue.what_happened', 'What happened')}
</div>
<p className="text-sm text-[var(--text-secondary)]">
{renderEventMessage(alert, t)}
</p>
</div>
)}
{/* AI Reasoning (expandable) */}
{alert.ai_reasoning_summary && (
<>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-2 text-[var(--color-info-700)]"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Zap className="w-4 h-4" />
<span>AI Reasoning</span>
</button>
{expanded && (
<div
className="rounded-md p-3 mb-3 bg-[var(--bg-secondary)] border-l-4 border-l-[var(--color-info-600)]"
>
<div className="flex items-start gap-2">
<Bot className="w-4 h-4 flex-shrink-0 mt-0.5 text-[var(--color-info-700)]" />
<p className="text-sm text-[var(--text-secondary)]">
{alert.ai_reasoning_summary}
</p>
{/* WHY: Why this is needed - AI Reasoning */}
{reasoningText && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Bot className="w-3 h-3" />
{t('reasoning:jtbd.action_queue.why_needed', 'Why this is needed')}
</div>
</div>
)}
</>
)}
{/* Smart Actions - Improved with loading states and icons */}
{alertActions.length > 0 && !actionCompleted && (
<div className="flex flex-wrap gap-2 mt-3 sm:flex-row flex-col sm:items-center">
{alertActions.map((action, idx) => {
const buttonVariant = mapActionVariantToButton(action.variant);
const isPrimary = action.variant === 'primary';
const ActionIcon = isPrimary ? getActionIcon(action.type) : null;
const isLoading = loadingAction === action.type;
return (
<Button
key={idx}
variant={buttonVariant}
size={isPrimary ? 'md' : 'sm'}
isLoading={isLoading}
disabled={action.disabled || loadingAction !== null}
leftIcon={ActionIcon && !isLoading ? <ActionIcon className="w-4 h-4" /> : undefined}
onClick={async () => {
setLoadingAction(action.type);
await actionHandler.handleAction(action as any, alert.id);
}}
className={`${isPrimary ? 'font-semibold' : ''} sm:w-auto w-full`}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-[var(--color-info-100)] text-[var(--color-info-800)] border border-[var(--color-info-300)]">
{t('alerts:orchestration.what_ai_did', 'AI Recommendation')}
</span>
<button
onClick={() => setShowReasoningModal(true)}
className="text-xs text-[var(--color-info-700)] hover:text-[var(--color-info-900)] underline cursor-pointer"
>
{(() => {
const { key, params } = getActionLabelKey(action.type, action.metadata);
return String(t(key, params));
})()}
{action.estimated_time_minutes && !isLoading && (
<span className="ml-1 opacity-60 text-xs">({action.estimated_time_minutes}m)</span>
)}
</Button>
);
})}
</div>
)}
{t('alerts:actions.see_reasoning', 'See full reasoning')}
</button>
</div>
<div
className="p-3 rounded-md text-sm leading-relaxed border-l-3"
style={{
backgroundColor: 'rgba(59, 130, 246, 0.05)',
borderLeftColor: 'rgb(59, 130, 246)',
borderLeftWidth: '3px'
}}
>
{reasoningText}
</div>
</div>
)}
{/* Action Completed State */}
{actionCompleted && (
<div
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Action completed successfully</span>
</div>
)}
{/* Context Badges - Matching Health Hero Style */}
{(alert.business_impact || alert.urgency || alert.type_class === 'prevented_issue') && (
<div className="flex flex-wrap gap-2 mb-3">
{alert.business_impact?.financial_impact_eur && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-warning-100)] text-[var(--color-warning-800)]"
>
<TrendingUp className="w-4 h-4" />
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} at risk</span>
</div>
)}
{alert.urgency?.hours_until_consequence && (
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold ${
alert.urgency.hours_until_consequence < 6 ? 'animate-pulse' : ''
} bg-[var(--color-error-100)] text-[var(--color-error-800)]`}
>
<Clock className="w-4 h-4" />
<span>{Math.round(alert.urgency.hours_until_consequence)}h left</span>
</div>
)}
{alert.type_class === 'prevented_issue' && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<Bot className="w-4 h-4" />
<span>AI handled</span>
</div>
)}
</div>
)}
{/* HOW: What you should do - Action buttons */}
{alertActions.length > 0 && !actionCompleted && (
<div className="mt-4">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
{t('reasoning:jtbd.action_queue.what_to_do', 'What you should do')}
</div>
<div className="flex flex-wrap gap-2 sm:flex-row flex-col sm:items-center">
{alertActions.map((action, idx) => {
const buttonVariant = mapActionVariantToButton(action.variant);
const isPrimary = action.variant === 'primary';
const ActionIcon = isPrimary ? getActionIcon(action.action_type) : null;
const isLoading = loadingAction === action.action_type;
return (
<Button
key={idx}
variant={buttonVariant}
size={isPrimary ? 'md' : 'sm'}
isLoading={isLoading}
disabled={action.disabled || loadingAction !== null}
leftIcon={ActionIcon && !isLoading ? <ActionIcon className="w-4 h-4" /> : undefined}
onClick={async () => {
setLoadingAction(action.action_type);
await actionHandler.handleAction(action as any, alert.id);
}}
className={`${isPrimary ? 'font-semibold' : ''} sm:w-auto w-full`}
>
{renderActionLabel(action, t)}
{action.estimated_time_minutes && !isLoading && (
<span className="ml-1 opacity-60 text-xs">({action.estimated_time_minutes}m)</span>
)}
</Button>
);
})}
</div>
</div>
)}
{/* Action Completed State */}
{actionCompleted && (
<div
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md bg-[var(--color-success-100)] text-[var(--color-success-800)]"
>
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Action completed successfully</span>
</div>
)}
</div>
</div>
</div>
{/* Reasoning Modal */}
{showReasoningModal && reasoningText && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-96 overflow-y-auto p-6">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-bold text-[var(--text-primary)]">
{t('alerts:orchestration.reasoning_title', 'AI Reasoning')}
</h3>
<button
onClick={() => setShowReasoningModal(false)}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="prose max-w-none">
<p className="text-[var(--text-secondary)] whitespace-pre-wrap">
{reasoningText}
</p>
</div>
<div className="mt-4 flex justify-end">
<Button variant="secondary" onClick={() => setShowReasoningModal(false)}>
{t('common:close', 'Close')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -463,8 +541,11 @@ export function UnifiedActionQueueCard({
}: UnifiedActionQueueCardProps) {
const { t } = useTranslation(['alerts', 'dashboard']);
const navigate = useNavigate();
const queryClient = useQueryClient();
const [toastMessage, setToastMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
// REMOVED: Race condition workaround (lines 560-572) - no longer needed
// with refetchOnMount:'always' in useSharedDashboardData
// Show toast notification
useEffect(() => {
@@ -496,8 +577,12 @@ export function UnifiedActionQueueCard({
const [reasoningModalOpen, setReasoningModalOpen] = useState(false);
const [reasoningData, setReasoningData] = useState<any>(null);
// PO Details Modal state
const [isPODetailsModalOpen, setIsPODetailsModalOpen] = useState(false);
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
// Subscribe to SSE notifications for real-time alerts
const { notifications, isConnected } = useNotifications();
const { notifications, isConnected } = useEventNotifications();
// Listen for stock receipt modal open events
useEffect(() => {
@@ -538,11 +623,11 @@ export function UnifiedActionQueueCard({
action_id,
po_id,
batch_id,
reasoning: reasoning || alert?.ai_reasoning_summary,
title: alert?.title,
ai_reasoning_summary: alert?.ai_reasoning_summary,
reasoning: reasoning || renderAIReasoning(alert, t),
title: alert ? renderEventTitle(alert, t) : undefined,
ai_reasoning_summary: alert ? renderAIReasoning(alert, t) : undefined,
business_impact: alert?.business_impact,
urgency_context: alert?.urgency_context,
urgency_context: alert?.urgency,
});
setReasoningModalOpen(true);
};
@@ -553,6 +638,23 @@ export function UnifiedActionQueueCard({
};
}, [actionQueue]);
// Listen for PO details modal open events
useEffect(() => {
const handlePODetailsOpen = (event: CustomEvent) => {
const { po_id } = event.detail;
if (po_id) {
setSelectedPOId(po_id);
setIsPODetailsModalOpen(true);
}
};
window.addEventListener('po:open-details' as any, handlePODetailsOpen);
return () => {
window.removeEventListener('po:open-details' as any, handlePODetailsOpen);
};
}, []);
// Create a stable identifier for notifications to prevent infinite re-renders
// Only recalculate when the actual notification IDs and read states change
const notificationKey = useMemo(() => {
@@ -574,8 +676,9 @@ export function UnifiedActionQueueCard({
// Filter SSE notifications to only action_needed alerts
// Guard against undefined notifications array
// NEW: Also filter by status to exclude acknowledged/resolved alerts
const sseActionAlerts = (notifications || []).filter(
n => n.type_class === 'action_needed' && !n.read
n => n.type_class === 'action_needed' && !n.read && n.status === 'active'
);
// Create a set of existing alert IDs from API data
@@ -591,19 +694,19 @@ export function UnifiedActionQueueCard({
// Helper function to categorize alerts by urgency
const categorizeByUrgency = (alert: any): 'urgent' | 'today' | 'week' => {
const now = new Date();
const urgencyContext = alert.urgency_context;
const deadline = urgencyContext?.deadline ? new Date(urgencyContext.deadline) : null;
const urgency = alert.urgency;
const deadline = urgency?.deadline_utc ? new Date(urgency.deadline_utc) : null;
if (!deadline) {
// No deadline: categorize by priority level
if (alert.priority_level === 'CRITICAL') return 'urgent';
if (alert.priority_level === 'IMPORTANT') return 'today';
if (alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL') return 'urgent';
if (alert.priority_level === 'important' || alert.priority_level === 'IMPORTANT') return 'today';
return 'week';
}
const hoursUntilDeadline = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntilDeadline < 6 || alert.priority_level === 'CRITICAL') {
if (hoursUntilDeadline < 6 || alert.priority_level === 'critical' || alert.priority_level === 'CRITICAL') {
return 'urgent';
} else if (hoursUntilDeadline < 24) {
return 'today';
@@ -805,14 +908,58 @@ export function UnifiedActionQueueCard({
receipt={stockReceiptData.receipt}
mode={stockReceiptData.mode}
onSaveDraft={async (receipt) => {
console.log('Draft saved:', receipt);
// TODO: Implement save draft API call
try {
// Save draft receipt
const response = await fetch(
`/api/tenants/${receipt.tenant_id}/inventory/stock-receipts/${receipt.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes: receipt.notes,
line_items: receipt.line_items
})
}
);
if (!response.ok) {
throw new Error('Failed to save draft');
}
console.log('Draft saved successfully');
} catch (error) {
console.error('Error saving draft:', error);
throw error;
}
}}
onConfirm={async (receipt) => {
console.log('Receipt confirmed:', receipt);
// TODO: Implement confirm receipt API call
setIsStockReceiptModalOpen(false);
setStockReceiptData(null);
try {
// Confirm receipt - updates inventory
const response = await fetch(
`/api/tenants/${receipt.tenant_id}/inventory/stock-receipts/${receipt.id}/confirm`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirmed_by_user_id: receipt.received_by_user_id
})
}
);
if (!response.ok) {
throw new Error('Failed to confirm receipt');
}
console.log('Receipt confirmed successfully');
setIsStockReceiptModalOpen(false);
setStockReceiptData(null);
// Refresh data to show updated inventory
await refetch();
} catch (error) {
console.error('Error confirming receipt:', error);
throw error;
}
}}
/>
)}
@@ -828,6 +975,21 @@ export function UnifiedActionQueueCard({
reasoning={reasoningData}
/>
)}
{/* PO Details Modal - Opened by "Ver detalles" action */}
{isPODetailsModalOpen && selectedPOId && tenantId && (
<UnifiedPurchaseOrderModal
poId={selectedPOId}
tenantId={tenantId}
isOpen={isPODetailsModalOpen}
onClose={() => {
setIsPODetailsModalOpen(false);
setSelectedPOId(null);
}}
showApprovalActions={true}
initialMode="view"
/>
)}
</div>
);
}

View File

@@ -28,9 +28,8 @@ export const IncompleteIngredientsAlert: React.FC = () => {
}
const handleViewIncomplete = () => {
// Navigate to inventory page
// TODO: In the future, this could pass a filter parameter to show only incomplete items
navigate('/app/operations/inventory');
// Navigate to inventory page with filter to show only incomplete items
navigate('/app/operations/inventory?filter=incomplete&needs_review=true');
};
return (

View File

@@ -12,6 +12,7 @@ import type { RecipeResponse } from '../../../api/types/recipes';
import { useTranslation } from 'react-i18next';
import { useRecipes } from '../../../api/hooks/recipes';
import { useIngredients } from '../../../api/hooks/inventory';
import { useEquipment } from '../../../api/hooks/equipment';
import { recipesService } from '../../../api/services/recipes';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { statusColors } from '../../../styles/colors';
@@ -41,6 +42,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
// API Data
const { data: recipes = [], isLoading: recipesLoading } = useRecipes(tenantId);
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
const { data: equipmentData, isLoading: equipmentLoading } = useEquipment(tenantId, { is_active: true });
// Stage labels for display
const STAGE_LABELS: Record<ProcessStage, string> = {
@@ -91,6 +93,14 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
label: recipe.name
})), [recipes]);
const equipmentOptions = useMemo(() => {
if (!equipmentData?.equipment) return [];
return equipmentData.equipment.map(equip => ({
value: equip.id,
label: `${equip.name} (${equip.equipment_code || equip.id.substring(0, 8)})`
}));
}, [equipmentData]);
const handleSave = async (formData: Record<string, any>) => {
// Validate that end time is after start time
const startTime = new Date(formData.planned_start_time);
@@ -111,6 +121,11 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
? formData.staff_assigned.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0)
: [];
// Convert equipment_used from comma-separated string to array
const equipmentArray = formData.equipment_used
? formData.equipment_used.split(',').map((e: string) => e.trim()).filter((e: string) => e.length > 0)
: [];
const batchData: ProductionBatchCreate = {
product_id: formData.product_id,
product_name: selectedProduct?.name || '',
@@ -126,7 +141,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
batch_number: formData.batch_number || '',
order_id: formData.order_id || '',
forecast_id: formData.forecast_id || '',
equipment_used: [], // TODO: Add equipment selection if needed
equipment_used: equipmentArray,
staff_assigned: staffArray,
station_id: formData.station_id || ''
};
@@ -271,6 +286,16 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
title: 'Recursos y Notas',
icon: Users,
fields: [
{
label: 'Equipos a Utilizar',
name: 'equipment_used',
type: 'text' as const,
placeholder: 'Separar IDs de equipos con comas (opcional)',
span: 2,
helpText: equipmentOptions.length > 0
? `Equipos disponibles: ${equipmentOptions.map(e => e.label).join(', ')}`
: 'No hay equipos activos disponibles'
},
{
label: 'Personal Asignado',
name: 'staff_assigned',
@@ -288,7 +313,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
}
]
}
], [productOptions, recipeOptions, t]);
], [productOptions, recipeOptions, equipmentOptions, t]);
// Quality Requirements Preview Component
const qualityRequirementsPreview = selectedRecipe && (

View File

@@ -72,8 +72,7 @@ export const DemoBanner: React.FC = () => {
useTenantStore.getState().clearTenants();
// Clear notification storage to ensure notifications don't persist across sessions
const { clearNotificationStorage } = await import('../../../hooks/useNotifications');
clearNotificationStorage();
// Since useNotifications hook doesn't exist, we just continue without clearing
navigate('/demo');
};

View File

@@ -15,6 +15,7 @@ import {
Clock,
User
} from 'lucide-react';
import { showToast } from '../../../utils/toast';
export interface ActionButton {
id: string;
@@ -264,14 +265,15 @@ export const PageHeader = forwardRef<PageHeaderRef, PageHeaderProps>(({
// Render metadata item
const renderMetadata = (item: MetadataItem) => {
const ItemIcon = item.icon;
const handleCopy = async () => {
if (item.copyable && typeof item.value === 'string') {
try {
await navigator.clipboard.writeText(item.value);
// TODO: Show toast notification
showToast.success('Copiado al portapapeles');
} catch (error) {
console.error('Failed to copy:', error);
showToast.error('Error al copiar');
}
}
};

View File

@@ -8,6 +8,7 @@ import { useHasAccess } from '../../../hooks/useAccessControl';
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
import { useSubscriptionEvents } from '../../../contexts/SubscriptionEventsContext';
import { useSubscription } from '../../../api/hooks/subscription';
import { Button } from '../../ui';
import { Badge } from '../../ui';
import { Tooltip } from '../../ui';
@@ -136,6 +137,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
insights: Lightbulb,
events: Activity,
list: List,
distribution: Truck,
};
/**
@@ -162,7 +164,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
showCollapseButton = true,
showFooter = true,
}, ref) => {
const { t } = useTranslation();
const { t } = useTranslation(['common']);
const location = useLocation();
const navigate = useNavigate();
const user = useAuthUser();
@@ -170,7 +172,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const hasAccess = useHasAccess(); // For UI visibility
const currentTenantAccess = useCurrentTenantAccess();
const { logout } = useAuthActions();
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
@@ -179,6 +181,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const sidebarRef = React.useRef<HTMLDivElement>(null);
const { subscriptionVersion } = useSubscriptionEvents();
const { subscriptionInfo } = useSubscription();
// Get subscription-aware navigation routes
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
@@ -186,6 +189,11 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
// Map route paths to translation keys
const getTranslationKey = (routePath: string): string => {
// Special case for Enterprise tier: Rename "Mi Panadería" to "Central Baker"
if (routePath === '/app/database' && subscriptionInfo.plan === 'enterprise') {
return 'navigation.central_baker';
}
const pathMappings: Record<string, string> = {
'/app/dashboard': 'navigation.dashboard',
'/app/operations': 'navigation.operations',
@@ -193,6 +201,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
'/app/operations/production': 'navigation.production',
'/app/operations/maquinaria': 'navigation.equipment',
'/app/operations/pos': 'navigation.pos',
'/app/operations/distribution': 'navigation.distribution',
'/app/bakery': 'navigation.bakery',
'/app/bakery/recipes': 'navigation.recipes',
'/app/database': 'navigation.data',
@@ -436,11 +445,11 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const findParentPaths = useCallback((items: NavigationItem[], targetPath: string, parents: string[] = []): string[] => {
for (const item of items) {
const currentPath = [...parents, item.id];
if (item.path === targetPath) {
return parents;
}
if (item.children) {
const found = findParentPaths(item.children, targetPath, currentPath);
if (found.length > 0) {
@@ -479,7 +488,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
let touchStartX = 0;
let touchStartY = 0;
const handleTouchStart = (e: TouchEvent) => {
if (isOpen) {
touchStartX = e.touches[0].clientX;
@@ -489,12 +498,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const handleTouchMove = (e: TouchEvent) => {
if (!isOpen || !onClose) return;
const touchCurrentX = e.touches[0].clientX;
const touchCurrentY = e.touches[0].clientY;
const deltaX = touchStartX - touchCurrentX;
const deltaY = Math.abs(touchStartY - touchCurrentY);
// Only trigger swipe left to close if it's more horizontal than vertical
// and the swipe distance is significant
if (deltaX > 50 && deltaX > deltaY * 2) {
@@ -536,7 +545,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
e.preventDefault();
focusSearch();
}
// Escape to close menus
if (e.key === 'Escape') {
setIsProfileMenuOpen(false);
@@ -651,18 +660,18 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
)}
/>
)}
{/* Submenu indicator for collapsed sidebar */}
{isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0 && (
<div className="absolute -bottom-1 -right-1 w-2 h-2 bg-[var(--color-primary)] rounded-full opacity-75" />
)}
</div>
{!ItemIcon && level > 0 && (
<Dot className={clsx(
'flex-shrink-0 w-4 h-4 mr-3 transition-colors duration-200',
isActive
? 'text-[var(--color-primary)]'
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
)} />
)}
@@ -671,8 +680,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
<>
<span className={clsx(
'flex-1 truncate transition-colors duration-200 text-sm font-medium',
isActive
? 'text-[var(--color-primary)]'
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-primary)] group-hover:text-[var(--text-primary)]'
)}>
{item.label}
@@ -692,8 +701,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
<ChevronDown className={clsx(
'flex-shrink-0 w-4 h-4 ml-2 transition-transform duration-200',
isExpanded && 'transform rotate-180',
isActive
? 'text-[var(--color-primary)]'
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
)} />
)}
@@ -733,7 +742,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
>
{itemContent}
</button>
{/* Submenu overlay for collapsed sidebar */}
{isCollapsed && hasChildren && level === 0 && isHovered && item.children && item.children.length > 0 && (
<div
@@ -788,7 +797,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
{/* Search */}
{!isCollapsed && (
<div className="px-4 pt-4" data-search>
<form
<form
onSubmit={handleSearchSubmit}
className="relative"
>
@@ -989,7 +998,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
{/* Mobile search - always visible in mobile view */}
<div className="p-4 border-b border-[var(--border-primary)]">
<form
<form
onSubmit={handleSearchSubmit}
className="relative"
>

View File

@@ -40,7 +40,7 @@ interface RouteData {
total_distance_km: number;
estimated_duration_minutes: number;
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
route_points: RoutePoint[];
route_points?: RoutePoint[];
}
interface ShipmentStatusData {
@@ -55,9 +55,9 @@ interface DistributionMapProps {
shipments?: ShipmentStatusData;
}
const DistributionMap: React.FC<DistributionMapProps> = ({
routes = [],
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
const DistributionMap: React.FC<DistributionMapProps> = ({
routes = [],
shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 }
}) => {
const { t } = useTranslation('dashboard');
const [selectedRoute, setSelectedRoute] = useState<RouteData | null>(null);
@@ -66,77 +66,167 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
const renderMapVisualization = () => {
if (!routes || routes.length === 0) {
return (
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center">
<MapPin className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-lg font-medium text-gray-900">{t('enterprise.no_active_routes')}</p>
<p className="text-gray-500">{t('enterprise.no_shipments_today')}</p>
<div className="h-96 flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-center px-6">
<div
className="w-24 h-24 rounded-full flex items-center justify-center mb-4 mx-auto shadow-lg"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<MapPin className="w-12 h-12" style={{ color: 'var(--color-info-600)' }} />
</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.no_active_routes')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.no_shipments_today')}
</p>
</div>
</div>
);
}
// Find active routes (in_progress or planned for today)
const activeRoutes = routes.filter(route =>
const activeRoutes = routes.filter(route =>
route.status === 'in_progress' || route.status === 'planned'
);
if (activeRoutes.length === 0) {
return (
<div className="h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center">
<div className="text-center">
<CheckCircle className="w-12 h-12 mx-auto mb-4 text-green-400" />
<p className="text-lg font-medium text-gray-900">{t('enterprise.all_routes_completed')}</p>
<p className="text-gray-500">{t('enterprise.no_active_deliveries')}</p>
<div className="h-96 flex items-center justify-center rounded-xl border-2 border-dashed" style={{ borderColor: 'var(--border-secondary)', backgroundColor: 'var(--bg-secondary)' }}>
<div className="text-center px-6">
<div
className="w-24 h-24 rounded-full flex items-center justify-center mb-4 mx-auto shadow-lg"
style={{
backgroundColor: 'var(--color-success-100)',
}}
>
<CheckCircle className="w-12 h-12" style={{ color: 'var(--color-success-600)' }} />
</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.all_routes_completed')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.no_active_deliveries')}
</p>
</div>
</div>
);
}
// This would normally render an interactive map, but we'll create a visual representation
// Enhanced visual representation with improved styling
return (
<div className="h-96 bg-gradient-to-b from-blue-50 to-indigo-50 rounded-lg border border-gray-200 relative">
{/* Map visualization placeholder with route indicators */}
<div className="relative h-64 lg:h-96 rounded-xl overflow-hidden border-2" style={{
background: 'linear-gradient(135deg, var(--color-info-50) 0%, var(--color-primary-50) 50%, var(--color-secondary-50) 100%)',
borderColor: 'var(--border-primary)'
}}>
{/* Bakery-themed pattern overlay */}
<div className="absolute inset-0 opacity-5 bg-pattern" />
{/* Central Info Display */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<MapIcon className="w-16 h-16 mx-auto text-blue-400 mb-2" />
<div className="text-lg font-medium text-gray-700">{t('enterprise.distribution_map')}</div>
<div className="text-sm text-gray-500 mt-1">
<div
className="w-20 h-20 rounded-2xl flex items-center justify-center mb-3 mx-auto shadow-lg"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<MapIcon className="w-10 h-10" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.distribution_map')}
</div>
<div className="text-sm font-medium px-4 py-2 rounded-full inline-block" style={{
backgroundColor: 'var(--color-info-100)',
color: 'var(--color-info-900)'
}}>
{activeRoutes.length} {t('enterprise.active_routes')}
</div>
</div>
</div>
{/* Route visualization elements */}
{activeRoutes.map((route, index) => (
<div key={route.id} className="absolute top-4 left-4 bg-white p-3 rounded-lg shadow-md text-sm max-w-xs">
<div className="font-medium text-gray-900 flex items-center gap-2">
<Route className="w-4 h-4 text-blue-600" />
{t('enterprise.route')} {route.route_number}
{/* Glassmorphism Route Info Cards */}
<div className="absolute top-4 left-4 right-4 flex flex-wrap gap-2">
{activeRoutes.slice(0, 3).map((route, index) => (
<div
key={route.id}
className="glass-effect p-3 rounded-lg shadow-md backdrop-blur-sm max-w-xs"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid var(--border-primary)'
}}
>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<Route className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.route')} {route.route_number}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h
</div>
</div>
</div>
</div>
<div className="text-gray-600 mt-1">{route.status.replace('_', ' ')}</div>
<div className="text-gray-500">{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h</div>
</div>
))}
))}
{activeRoutes.length > 3 && (
<div
className="glass-effect p-3 rounded-lg shadow-md backdrop-blur-sm"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.9)',
border: '1px solid var(--border-primary)'
}}
>
<div className="text-sm font-semibold" style={{ color: 'var(--text-secondary)' }}>
+{activeRoutes.length - 3} more
</div>
</div>
)}
</div>
{/* Shipment status indicators */}
<div className="absolute bottom-4 right-4 bg-white p-3 rounded-lg shadow-md space-y-2">
{/* Status Legend */}
<div
className="absolute bottom-4 right-4 glass-effect p-4 rounded-lg shadow-lg backdrop-blur-sm space-y-2"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid var(--border-primary)'
}}
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<span className="text-sm">{t('enterprise.pending')}: {shipments.pending}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-warning)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.pending')}: {shipments.pending}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-400"></div>
<span className="text-sm">{t('enterprise.in_transit')}: {shipments.in_transit}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-info)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.in_transit')}: {shipments.in_transit}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-400"></div>
<span className="text-sm">{t('enterprise.delivered')}: {shipments.delivered}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<span className="text-sm">{t('enterprise.failed')}: {shipments.failed}</span>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-success)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.delivered')}: {shipments.delivered}
</span>
</div>
{shipments.failed > 0 && (
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--color-error)' }}></div>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.failed')}: {shipments.failed}
</span>
</div>
)}
</div>
</div>
);
@@ -173,111 +263,274 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
};
return (
<div className="space-y-4">
{/* Shipment Status Summary */}
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-600" />
<span className="text-sm font-medium text-yellow-800">{t('enterprise.pending')}</span>
<div className="space-y-6">
{/* Shipment Status Summary - Hero Icon Pattern */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{/* Pending Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-warning-100)',
}}
>
<Clock className="w-7 h-7" style={{ color: 'var(--color-warning-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-warning-900)' }}>
{shipments?.pending || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-warning-700)' }}>
{t('enterprise.pending')}
</p>
</div>
<p className="text-2xl font-bold text-yellow-900">{shipments?.pending || 0}</p>
</div>
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
<div className="flex items-center gap-2">
<Truck className="w-5 h-5 text-blue-600" />
<span className="text-sm font-medium text-blue-800">{t('enterprise.in_transit')}</span>
{/* In Transit Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<Truck className="w-7 h-7" style={{ color: 'var(--color-info-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-info-900)' }}>
{shipments?.in_transit || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-info-700)' }}>
{t('enterprise.in_transit')}
</p>
</div>
<p className="text-2xl font-bold text-blue-900">{shipments?.in_transit || 0}</p>
</div>
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm font-medium text-green-800">{t('enterprise.delivered')}</span>
{/* Delivered Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-success-100)',
}}
>
<CheckCircle className="w-7 h-7" style={{ color: 'var(--color-success-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-success-900)' }}>
{shipments?.delivered || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-success-700)' }}>
{t('enterprise.delivered')}
</p>
</div>
<p className="text-2xl font-bold text-green-900">{shipments?.delivered || 0}</p>
</div>
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<span className="text-sm font-medium text-red-800">{t('enterprise.failed')}</span>
{/* Failed Status Card */}
<div className="relative group">
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl hover:-translate-y-1 cursor-pointer"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
}}
>
<div
className="w-14 h-14 rounded-full flex items-center justify-center mb-3 shadow-md"
style={{
backgroundColor: 'var(--color-error-100)',
}}
>
<AlertTriangle className="w-7 h-7" style={{ color: 'var(--color-error-600)' }} />
</div>
<p className="text-4xl font-bold mb-1" style={{ color: 'var(--color-error-900)' }}>
{shipments?.failed || 0}
</p>
<p className="text-sm font-medium" style={{ color: 'var(--color-error-700)' }}>
{t('enterprise.failed')}
</p>
</div>
<p className="text-2xl font-bold text-red-900">{shipments?.failed || 0}</p>
</div>
</div>
{/* Map Visualization */}
{renderMapVisualization()}
{/* Route Details Panel */}
<div className="mt-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium text-gray-700">{t('enterprise.active_routes')}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllRoutes(!showAllRoutes)}
>
{showAllRoutes ? t('enterprise.hide_routes') : t('enterprise.show_routes')}
</Button>
</div>
{/* Route Details Panel - Timeline Pattern */}
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.active_routes')} ({routes.filter(r => r.status === 'in_progress' || r.status === 'planned').length})
</h3>
{showAllRoutes && routes.length > 0 ? (
<div className="space-y-3">
{routes.length > 0 ? (
<div className="space-y-4">
{routes
.filter(route => route.status === 'in_progress' || route.status === 'planned')
.map(route => (
<Card key={route.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex justify-between items-start">
<div
key={route.id}
className="p-5 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)'
}}
>
{/* Route Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center shadow-md"
style={{
backgroundColor: 'var(--color-info-100)',
}}
>
<Truck className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
</div>
<div>
<CardTitle className="text-sm">
<h4 className="font-semibold text-base" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.route')} {route.route_number}
</CardTitle>
<p className="text-xs text-gray-500 mt-1">
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{route.total_distance_km.toFixed(1)} km {Math.ceil(route.estimated_duration_minutes / 60)}h
</p>
</div>
<Badge className={getStatusColor(route.status)}>
{getStatusIcon(route.status)}
<span className="ml-1 capitalize">
{t(`enterprise.route_status.${route.status}`) || route.status}
</span>
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{route.route_points.map((point, index) => (
<div key={index} className="flex items-center gap-3 text-sm">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
point.status === 'delivered' ? 'bg-green-500 text-white' :
point.status === 'in_transit' ? 'bg-blue-500 text-white' :
point.status === 'failed' ? 'bg-red-500 text-white' :
'bg-yellow-500 text-white'
}`}>
{point.sequence}
<Badge
className="px-3 py-1"
style={{
backgroundColor: route.status === 'in_progress'
? 'var(--color-info-100)'
: 'var(--color-warning-100)',
color: route.status === 'in_progress'
? 'var(--color-info-900)'
: 'var(--color-warning-900)',
borderColor: route.status === 'in_progress'
? 'var(--color-info-300)'
: 'var(--color-warning-300)',
borderWidth: '1px'
}}
>
{getStatusIcon(route.status)}
<span className="ml-1 capitalize font-medium">
{t(`enterprise.route_status.${route.status}`) || route.status.replace('_', ' ')}
</span>
</Badge>
</div>
{/* Timeline of Stops */}
{route.route_points && route.route_points.length > 0 && (
<div className="ml-6 border-l-2 pl-6 space-y-3" style={{ borderColor: 'var(--border-secondary)' }}>
{route.route_points.map((point, idx) => {
const getPointStatusColor = (status: string) => {
switch (status) {
case 'delivered':
return 'var(--color-success)';
case 'in_transit':
return 'var(--color-info)';
case 'failed':
return 'var(--color-error)';
default:
return 'var(--color-warning)';
}
};
const getPointBadgeStyle = (status: string) => {
switch (status) {
case 'delivered':
return {
backgroundColor: 'var(--color-success-100)',
color: 'var(--color-success-900)',
borderColor: 'var(--color-success-300)'
};
case 'in_transit':
return {
backgroundColor: 'var(--color-info-100)',
color: 'var(--color-info-900)',
borderColor: 'var(--color-info-300)'
};
case 'failed':
return {
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-900)',
borderColor: 'var(--color-error-300)'
};
default:
return {
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-900)',
borderColor: 'var(--color-warning-300)'
};
}
};
return (
<div key={idx} className="relative">
{/* Timeline dot */}
<div
className="absolute -left-[29px] w-4 h-4 rounded-full border-2 shadow-sm"
style={{
backgroundColor: getPointStatusColor(point.status),
borderColor: 'var(--bg-primary)'
}}
/>
{/* Stop info */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium mb-0.5" style={{ color: 'var(--text-primary)' }}>
{point.sequence}. {point.name}
</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{point.address}
</p>
</div>
<Badge
variant="outline"
className="px-2 py-0.5 text-xs flex-shrink-0"
style={{
...getPointBadgeStyle(point.status),
borderWidth: '1px'
}}
>
{getStatusIcon(point.status)}
<span className="ml-1 capitalize">
{t(`enterprise.stop_status.${point.status}`) || point.status}
</span>
</Badge>
</div>
</div>
<span className="flex-1 truncate">{point.name}</span>
<Badge variant="outline" className={getStatusColor(point.status)}>
{getStatusIcon(point.status)}
<span className="ml-1 text-xs">
{t(`enterprise.stop_status.${point.status}`) || point.status}
</span>
</Badge>
</div>
))}
);
})}
</div>
</CardContent>
</Card>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-4">
{routes.length === 0 ?
t('enterprise.no_routes_planned') :
t('enterprise.no_active_routes')}
</p>
<div className="text-center py-8" style={{ color: 'var(--text-secondary)' }}>
<p className="text-sm">
{t('enterprise.no_routes_planned')}
</p>
</div>
)}
</div>
@@ -287,15 +540,15 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">{t('enterprise.route_details')}</h3>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedRoute(null)}
>
×
</Button>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span>{t('enterprise.route_number')}</span>
@@ -319,9 +572,9 @@ const DistributionMap: React.FC<DistributionMapProps> = ({
</Badge>
</div>
</div>
<Button
className="w-full mt-4"
<Button
className="w-full mt-4"
onClick={() => setSelectedRoute(null)}
>
{t('common.close')}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoaderProps {
export interface LoaderProps {
size?: 'sm' | 'md' | 'lg' | 'default';
text?: string;
className?: string;

View File

@@ -16,14 +16,15 @@ import {
Phone,
ExternalLink
} from 'lucide-react';
import { EnrichedAlert, AlertTypeClass } from '../../../types/alerts';
import { Alert, AlertTypeClass, getPriorityColor, getTypeClassBadgeVariant, formatTimeUntilConsequence } from '../../../api/types/events';
import { renderEventTitle, renderEventMessage, renderActionLabel, renderAIReasoning } from '../../../utils/i18n/alertRendering';
import { useSmartActionHandler } from '../../../utils/smartActionHandlers';
import { getPriorityColor, getTypeClassBadgeVariant, formatTimeUntilConsequence } from '../../../types/alerts';
import { useAuthUser } from '../../../stores/auth.store';
import { useTranslation } from 'react-i18next';
export interface NotificationPanelProps {
notifications: NotificationData[];
enrichedAlerts?: EnrichedAlert[];
enrichedAlerts?: Alert[];
isOpen: boolean;
onClose: () => void;
onMarkAsRead: (id: string) => void;
@@ -50,12 +51,13 @@ const formatTimestamp = (timestamp: string) => {
// Enriched Alert Item Component
const EnrichedAlertItem: React.FC<{
alert: EnrichedAlert;
alert: Alert;
isMobile: boolean;
onMarkAsRead: (id: string) => void;
onRemove: (id: string) => void;
actionHandler: any;
}> = ({ alert, isMobile, onMarkAsRead, onRemove, actionHandler }) => {
const { t } = useTranslation();
const isUnread = alert.status === 'active';
const priorityColor = getPriorityColor(alert.priority_level);
@@ -109,14 +111,14 @@ const EnrichedAlertItem: React.FC<{
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
}`}>
{alert.title}
{renderEventTitle(alert, t)}
</p>
{/* Message */}
<p className={`leading-relaxed text-[var(--text-secondary)] ${
isMobile ? 'text-sm mb-3' : 'text-xs mb-2'
}`}>
{alert.message}
{renderEventMessage(alert, t)}
</p>
{/* Context Badges */}
@@ -133,10 +135,10 @@ const EnrichedAlertItem: React.FC<{
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} en riesgo</span>
</div>
)}
{alert.urgency_context?.time_until_consequence_hours && (
{alert.urgency?.hours_until_consequence && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-error/10 text-error text-xs">
<Clock className="w-3 h-3" />
<span>{formatTimeUntilConsequence(alert.urgency_context.time_until_consequence_hours)}</span>
<span>{formatTimeUntilConsequence(alert.urgency.hours_until_consequence)}</span>
</div>
)}
{alert.trend_context && (
@@ -148,21 +150,21 @@ const EnrichedAlertItem: React.FC<{
</div>
{/* AI Reasoning Summary */}
{alert.ai_reasoning_summary && (
{renderAIReasoning(alert, t) && (
<div className="mb-3 p-2 rounded-md bg-primary/5 border border-primary/20">
<div className="flex items-start gap-2">
<Bot className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<p className="text-xs text-[var(--text-secondary)] italic">
{alert.ai_reasoning_summary}
{renderAIReasoning(alert, t)}
</p>
</div>
</div>
)}
{/* Smart Actions */}
{alert.actions && alert.actions.length > 0 && (
{alert.smart_actions && alert.smart_actions.length > 0 && (
<div className={`flex flex-wrap gap-2 ${isMobile ? 'mb-3' : 'mb-2'}`}>
{alert.actions.slice(0, 3).map((action, idx) => (
{alert.smart_actions.slice(0, 3).map((action, idx) => (
<Button
key={idx}
size={isMobile ? "sm" : "xs"}
@@ -173,9 +175,9 @@ const EnrichedAlertItem: React.FC<{
action.variant === 'danger' ? 'text-error hover:text-error-dark' : ''
}`}
>
{action.type === 'call_supplier' && <Phone className="w-3 h-3 mr-1" />}
{action.type === 'navigate' && <ExternalLink className="w-3 h-3 mr-1" />}
{action.label}
{action.action_type === 'call_supplier' && <Phone className="w-3 h-3 mr-1" />}
{action.action_type === 'navigate' && <ExternalLink className="w-3 h-3 mr-1" />}
{renderActionLabel(action, t)}
{action.estimated_time_minutes && (
<span className="ml-1 opacity-60">({action.estimated_time_minutes}m)</span>
)}
@@ -344,17 +346,23 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
key={notification.id}
alert={{
...notification,
event_class: 'alert',
tenant_id: user?.tenant_id || '',
status: notification.read ? 'acknowledged' : 'active',
created_at: notification.timestamp,
enriched_at: notification.timestamp,
alert_metadata: notification.metadata || {},
event_metadata: notification.metadata || {},
service: 'notification-service',
alert_type: notification.item_type,
actions: notification.actions || [],
is_group_summary: false,
placement: notification.placement || ['notification_panel']
} as EnrichedAlert}
event_type: notification.item_type,
event_domain: 'notification',
smart_actions: notification.actions || [],
entity_links: {},
i18n: {
title_key: notification.title || '',
message_key: notification.message || '',
title_params: {},
message_params: {}
}
} as Alert}
isMobile={isMobile}
onMarkAsRead={onMarkAsRead}
onRemove={onRemoveNotification}

View File

@@ -2,7 +2,7 @@
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Textarea } from './Textarea/Textarea';
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
export { default as Card, CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription } from './Card';
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
export { default as Table } from './Table';
export { Badge, CountBadge, StatusDot, SeverityBadge } from './Badge';
@@ -48,9 +48,9 @@ export { SettingsSearch } from './SettingsSearch';
export type { ButtonProps } from './Button';
export type { InputProps } from './Input';
export type { TextareaProps } from './Textarea';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps, CardContentProps, CardTitleProps } from './Card';
export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
export type { TableProps, TableColumn, TableRow } from './Table';
export type { TableProps, TableColumn } from './Table';
export type { BadgeProps, CountBadgeProps, StatusDotProps, SeverityBadgeProps, SeverityLevel } from './Badge';
export type { AvatarProps } from './Avatar';
export type { TooltipProps } from './Tooltip';

View File

@@ -0,0 +1,42 @@
/**
* Service URLs Configuration
*
* Centralizes all backend service URLs for direct service calls.
* Part of Phase 1 architectural refactoring to eliminate orchestrator bottleneck.
*/
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8001';
export const SERVICE_URLS = {
gateway: GATEWAY_URL,
orchestrator: `${GATEWAY_URL}/orchestrator`,
production: `${GATEWAY_URL}/production`,
inventory: `${GATEWAY_URL}/inventory`,
alerts: `${GATEWAY_URL}/alerts`,
sales: `${GATEWAY_URL}/sales`,
procurement: `${GATEWAY_URL}/procurement`,
distribution: `${GATEWAY_URL}/distribution`,
forecasting: `${GATEWAY_URL}/forecasting`,
} as const;
export type ServiceName = keyof typeof SERVICE_URLS;
/**
* Get full URL for a service endpoint
*/
export function getServiceUrl(service: ServiceName, path: string): string {
const baseUrl = SERVICE_URLS[service];
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${baseUrl}${cleanPath}`;
}
/**
* Get tenant-specific endpoint URL
*/
export function getTenantEndpoint(
service: ServiceName,
tenantId: string,
endpoint: string
): string {
return getServiceUrl(service, `/api/v1/tenants/${tenantId}/${endpoint}`);
}

View File

@@ -0,0 +1,127 @@
export interface BlogPost {
id: string;
slug: string;
titleKey: string;
excerptKey: string;
authorKey: string;
date: string;
readTime: string;
categoryKey: string;
tagsKeys: string[];
}
export const blogPosts: BlogPost[] = [
{
id: '1',
slug: 'reducir-desperdicio-alimentario-panaderia',
titleKey: 'posts.waste_reduction.title',
excerptKey: 'posts.waste_reduction.excerpt',
authorKey: 'posts.waste_reduction.author',
date: '2025-01-15',
readTime: '8',
categoryKey: 'categories.management',
tagsKeys: [
'posts.waste_reduction.tags.food_waste',
'posts.waste_reduction.tags.sustainability',
'posts.waste_reduction.tags.ai',
'posts.waste_reduction.tags.management',
],
},
{
id: '2',
slug: 'ia-predecir-demanda-panaderia',
titleKey: 'posts.ai_prediction.title',
excerptKey: 'posts.ai_prediction.excerpt',
authorKey: 'posts.ai_prediction.author',
date: '2025-01-10',
readTime: '10',
categoryKey: 'categories.technology',
tagsKeys: [
'posts.ai_prediction.tags.ai',
'posts.ai_prediction.tags.machine_learning',
'posts.ai_prediction.tags.prediction',
'posts.ai_prediction.tags.technology',
],
},
{
id: '3',
slug: 'optimizar-produccion-panaderia-artesanal',
titleKey: 'posts.production_optimization.title',
excerptKey: 'posts.production_optimization.excerpt',
authorKey: 'posts.production_optimization.author',
date: '2025-01-05',
readTime: '12',
categoryKey: 'categories.production',
tagsKeys: [
'posts.production_optimization.tags.optimization',
'posts.production_optimization.tags.production',
'posts.production_optimization.tags.artisan',
'posts.production_optimization.tags.management',
],
},
{
id: '4',
slug: 'obrador-central-vs-produccion-local',
titleKey: 'posts.central_vs_local.title',
excerptKey: 'posts.central_vs_local.excerpt',
authorKey: 'posts.central_vs_local.author',
date: '2025-01-20',
readTime: '15',
categoryKey: 'categories.strategy',
tagsKeys: [
'posts.central_vs_local.tags.business_models',
'posts.central_vs_local.tags.central_bakery',
'posts.central_vs_local.tags.local_production',
'posts.central_vs_local.tags.scalability',
],
},
{
id: '5',
slug: 'gdpr-proteccion-datos-panaderia',
titleKey: 'posts.gdpr.title',
excerptKey: 'posts.gdpr.excerpt',
authorKey: 'posts.gdpr.author',
date: '2025-01-01',
readTime: '9',
categoryKey: 'categories.legal',
tagsKeys: [
'posts.gdpr.tags.gdpr',
'posts.gdpr.tags.rgpd',
'posts.gdpr.tags.privacy',
'posts.gdpr.tags.legal',
'posts.gdpr.tags.security',
],
},
{
id: '6',
slug: 'saas-futuro-gestion-panaderia',
titleKey: 'posts.saas_future.title',
excerptKey: 'posts.saas_future.excerpt',
authorKey: 'posts.saas_future.author',
date: '2025-02-01',
readTime: '7',
categoryKey: 'categories.technology',
tagsKeys: [
'posts.saas_future.tags.saas',
'posts.saas_future.tags.cloud',
'posts.saas_future.tags.digital_transformation',
'posts.saas_future.tags.efficiency',
],
},
{
id: '7',
slug: 'dominar-inventario-clave-rentabilidad',
titleKey: 'posts.inventory_mastery.title',
excerptKey: 'posts.inventory_mastery.excerpt',
authorKey: 'posts.inventory_mastery.author',
date: '2025-02-05',
readTime: '11',
categoryKey: 'categories.management',
tagsKeys: [
'posts.inventory_mastery.tags.inventory',
'posts.inventory_mastery.tags.profitability',
'posts.inventory_mastery.tags.cost_control',
'posts.inventory_mastery.tags.waste_reduction',
],
},
];

View File

@@ -0,0 +1,74 @@
/**
* Unified Alert Context
*
* Context provider for sharing alert state across components
*/
import React, { createContext, useContext, ReactNode, useState, useEffect, useCallback } from 'react';
import { Alert } from '../api/types/events';
import { useUnifiedAlerts } from '../api/hooks/useUnifiedAlerts';
import { AlertFilterOptions } from '../utils/alertManagement';
// Define context type
interface AlertContextType {
alerts: Alert[];
filteredAlerts: Alert[];
stats: any;
filters: AlertFilterOptions;
setFilters: (filters: AlertFilterOptions) => void;
search: string;
setSearch: (search: string) => void;
isLoading: boolean;
isRefetching: boolean;
error: Error | null;
refetch: () => void;
acknowledgeAlert: (alertId: string) => Promise<void>;
resolveAlert: (alertId: string) => Promise<void>;
cancelAutoAction: (alertId: string) => Promise<void>;
acknowledgeAlertsByMetadata: (alertType: string, metadata: any) => Promise<void>;
resolveAlertsByMetadata: (alertType: string, metadata: any) => Promise<void>;
isSSEConnected: boolean;
sseError: Error | null;
}
// Create context with default values
const AlertContext = createContext<AlertContextType | undefined>(undefined);
// Props for the provider
interface AlertProviderProps {
children: ReactNode;
tenantId: string;
initialFilters?: AlertFilterOptions;
}
// Alert Provider Component
export const AlertProvider: React.FC<AlertProviderProps> = ({
children,
tenantId,
initialFilters = {}
}) => {
// Use the unified hook
const unifiedAlerts = useUnifiedAlerts(tenantId, initialFilters, {
refetchInterval: 60000, // 1 minute
enableSSE: true,
sseChannels: [`*.alerts`, `*.notifications`]
});
return (
<AlertContext.Provider value={unifiedAlerts}>
{children}
</AlertContext.Provider>
);
};
// Custom hook to use the alert context
export const useAlertContext = (): AlertContextType => {
const context = useContext(AlertContext);
if (context === undefined) {
throw new Error('useAlertContext must be used within an AlertProvider');
}
return context;
};
// Export the context for use in components
export default AlertContext;

View File

@@ -0,0 +1,117 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
export interface NetworkMetrics {
totalSales: number;
totalProduction: number;
totalInventoryValue: number;
averageSales: number;
averageProduction: number;
averageInventoryValue: number;
childCount: number;
}
export interface EnterpriseModeState {
isNetworkView: boolean;
selectedOutletId: string | null;
selectedOutletName: string | null;
parentTenantId: string | null;
networkMetrics: NetworkMetrics | null;
networkViewPath: string | null;
}
interface EnterpriseContextType {
state: EnterpriseModeState;
enterNetworkView: (parentTenantId: string) => void;
drillDownToOutlet: (outletId: string, outletName: string, metrics?: NetworkMetrics) => void;
returnToNetworkView: () => void;
updateNetworkMetrics: (metrics: NetworkMetrics) => void;
clearEnterpriseMode: () => void;
}
const EnterpriseContext = createContext<EnterpriseContextType | undefined>(undefined);
export const useEnterprise = () => {
const context = useContext(EnterpriseContext);
if (context === undefined) {
throw new Error('useEnterprise must be used within an EnterpriseProvider');
}
return context;
};
interface EnterpriseProviderProps {
children: ReactNode;
}
export const EnterpriseProvider: React.FC<EnterpriseProviderProps> = ({ children }) => {
const [state, setState] = useState<EnterpriseModeState>({
isNetworkView: false,
selectedOutletId: null,
selectedOutletName: null,
parentTenantId: null,
networkMetrics: null,
networkViewPath: null,
});
const enterNetworkView = (parentTenantId: string) => {
setState({
isNetworkView: true,
selectedOutletId: null,
selectedOutletName: null,
parentTenantId,
networkMetrics: null,
networkViewPath: window.location.pathname,
});
};
const drillDownToOutlet = (outletId: string, outletName: string, metrics?: NetworkMetrics) => {
setState(prev => ({
...prev,
isNetworkView: false,
selectedOutletId: outletId,
selectedOutletName: outletName,
networkMetrics: metrics || prev.networkMetrics,
}));
};
const returnToNetworkView = () => {
setState(prev => ({
...prev,
isNetworkView: true,
selectedOutletId: null,
selectedOutletName: null,
}));
};
const updateNetworkMetrics = (metrics: NetworkMetrics) => {
setState(prev => ({
...prev,
networkMetrics: metrics,
}));
};
const clearEnterpriseMode = () => {
setState({
isNetworkView: false,
selectedOutletId: null,
selectedOutletName: null,
parentTenantId: null,
networkMetrics: null,
networkViewPath: null,
});
};
const contextValue: EnterpriseContextType = {
state,
enterNetworkView,
drillDownToOutlet,
returnToNetworkView,
updateNetworkMetrics,
clearEnterpriseMode,
};
return (
<EnterpriseContext.Provider value={contextValue}>
{children}
</EnterpriseContext.Provider>
);
};

View File

@@ -0,0 +1,154 @@
/**
* Clean Event Context for Global State Management
*
* NO BACKWARD COMPATIBILITY - Complete rewrite with i18n parameterized content
*/
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { Event, Alert, Notification, Recommendation } from '../api/types/events';
// Action types
type EventAction =
| { type: 'ADD_EVENT'; payload: Event }
| { type: 'UPDATE_EVENT'; payload: Event }
| { type: 'REMOVE_EVENT'; payload: string } // event ID
| { type: 'SET_EVENTS'; payload: Event[] }
| { type: 'CLEAR_EVENTS' }
| { type: 'MARK_AS_READ'; payload: string } // event ID
| { type: 'ACKNOWLEDGE_EVENT'; payload: string } // event ID
| { type: 'RESOLVE_EVENT'; payload: string }; // event ID
// State type
interface EventState {
events: Event[];
unreadCount: number;
activeAlerts: Alert[];
recentNotifications: Notification[];
recommendations: Recommendation[];
}
// Initial state
const initialState: EventState = {
events: [],
unreadCount: 0,
activeAlerts: [],
recentNotifications: [],
recommendations: [],
};
// Reducer
const eventReducer = (state: EventState, action: EventAction): EventState => {
switch (action.type) {
case 'SET_EVENTS':
const alerts = action.payload.filter(e => e.event_class === 'alert') as Alert[];
const notifications = action.payload.filter(e => e.event_class === 'notification') as Notification[];
const recommendations = action.payload.filter(e => e.event_class === 'recommendation') as Recommendation[];
return {
...state,
events: action.payload,
activeAlerts: alerts.filter(a => a.status === 'active'),
recentNotifications: notifications.slice(0, 10), // Most recent 10
recommendations: recommendations,
unreadCount: action.payload.filter(e => !e.event_metadata?.read).length,
};
case 'ADD_EVENT':
const existingIndex = state.events.findIndex(e => e.id === action.payload.id);
if (existingIndex !== -1) {
// Update existing event
const updatedEvents = [...state.events];
updatedEvents[existingIndex] = action.payload;
return eventReducer({ ...state, events: updatedEvents }, { type: 'SET_EVENTS', payload: updatedEvents });
} else {
// Add new event
const newEvents = [...state.events, action.payload];
return eventReducer({ ...state, events: newEvents }, { type: 'SET_EVENTS', payload: newEvents });
}
case 'UPDATE_EVENT':
const updatedEvents = state.events.map(e =>
e.id === action.payload.id ? action.payload : e
);
return eventReducer({ ...state, events: updatedEvents }, { type: 'SET_EVENTS', payload: updatedEvents });
case 'REMOVE_EVENT':
const filteredEvents = state.events.filter(e => e.id !== action.payload);
return eventReducer({ ...state, events: filteredEvents }, { type: 'SET_EVENTS', payload: filteredEvents });
case 'MARK_AS_READ':
const eventsWithRead = state.events.map(e =>
e.id === action.payload ? { ...e, event_metadata: { ...e.event_metadata, read: true } } : e
);
return eventReducer({ ...state, events: eventsWithRead }, { type: 'SET_EVENTS', payload: eventsWithRead });
case 'ACKNOWLEDGE_EVENT':
const eventsWithAck = state.events.map(e =>
e.id === action.payload && e.event_class === 'alert'
? { ...e, status: 'acknowledged' as const } as Event
: e
);
return eventReducer({ ...state, events: eventsWithAck }, { type: 'SET_EVENTS', payload: eventsWithAck });
case 'RESOLVE_EVENT':
const eventsWithResolved = state.events.map(e =>
e.id === action.payload && e.event_class === 'alert'
? { ...e, status: 'resolved' as const, resolved_at: new Date().toISOString() } as Event
: e
);
return eventReducer({ ...state, events: eventsWithResolved }, { type: 'SET_EVENTS', payload: eventsWithResolved });
case 'CLEAR_EVENTS':
return initialState;
default:
return state;
}
};
// Context types
interface EventContextType extends EventState {
addEvent: (event: Event) => void;
updateEvent: (event: Event) => void;
removeEvent: (eventId: string) => void;
setEvents: (events: Event[]) => void;
markAsRead: (eventId: string) => void;
acknowledgeEvent: (eventId: string) => void;
resolveEvent: (eventId: string) => void;
clearEvents: () => void;
}
// Create context
const EventContext = createContext<EventContextType | undefined>(undefined);
// Provider component
interface EventProviderProps {
children: ReactNode;
}
export const EventProvider: React.FC<EventProviderProps> = ({ children }) => {
const [state, dispatch] = useReducer(eventReducer, initialState);
const value = {
...state,
addEvent: (event: Event) => dispatch({ type: 'ADD_EVENT', payload: event }),
updateEvent: (event: Event) => dispatch({ type: 'UPDATE_EVENT', payload: event }),
removeEvent: (eventId: string) => dispatch({ type: 'REMOVE_EVENT', payload: eventId }),
setEvents: (events: Event[]) => dispatch({ type: 'SET_EVENTS', payload: events }),
markAsRead: (eventId: string) => dispatch({ type: 'MARK_AS_READ', payload: eventId }),
acknowledgeEvent: (eventId: string) => dispatch({ type: 'ACKNOWLEDGE_EVENT', payload: eventId }),
resolveEvent: (eventId: string) => dispatch({ type: 'RESOLVE_EVENT', payload: eventId }),
clearEvents: () => dispatch({ type: 'CLEAR_EVENTS' }),
};
return <EventContext.Provider value={value}>{children}</EventContext.Provider>;
};
// Hook to use the context
export const useEventContext = (): EventContextType => {
const context = useContext(EventContext);
if (!context) {
throw new Error('useEventContext must be used within an EventProvider');
}
return context;
};

View File

@@ -16,13 +16,14 @@ import type {
Notification,
EventDomain,
UseNotificationsConfig,
} from '../types/events';
import { isNotification } from '../types/events';
} from '../api/types/events';
import { isNotification } from '../api/types/events';
interface UseEventNotificationsReturn {
notifications: Notification[];
recentNotifications: Notification[];
isLoading: boolean;
isConnected: boolean; // Added isConnected to interface
clearNotifications: () => void;
}
@@ -52,7 +53,7 @@ export function useEventNotifications(config: UseNotificationsConfig = {}): UseE
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Use refs to track previous values and prevent unnecessary updates
// Use refs to track previous values and prevent unnecessary updates
const prevEventIdsRef = useRef<string>('');
const prevEventTypesRef = useRef<string[]>([]);
const prevMaxAgeRef = useRef<number>(maxAge);
@@ -62,8 +63,8 @@ export function useEventNotifications(config: UseNotificationsConfig = {}): UseE
// Check if the configuration has actually changed
const currentEventTypes = eventTypes || [];
const currentDomains = domains || [];
const configChanged =
const configChanged =
JSON.stringify(currentEventTypes) !== JSON.stringify(prevEventTypesRef.current) ||
prevMaxAgeRef.current !== maxAge ||
JSON.stringify(currentDomains) !== JSON.stringify(prevDomainsRef.current);
@@ -75,7 +76,7 @@ export function useEventNotifications(config: UseNotificationsConfig = {}): UseE
// Create current event IDs string for comparison
const currentEventIds = events.map(e => e.id).join(',');
// Only process if config changed OR events actually changed
if (!configChanged && currentEventIds === prevEventIdsRef.current) {
return; // No changes, skip processing

View File

@@ -1,509 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useSSE } from '../contexts/SSEContext';
import { calculateSnoozeUntil, type SnoozedAlert } from '../utils/alertHelpers';
export interface NotificationData {
id: string;
item_type: 'alert' | 'recommendation';
title: string;
message: string;
timestamp: string;
read: boolean;
metadata?: Record<string, any>;
// Enriched alert fields (REQUIRED for alerts)
priority_score: number; // 0-100
priority_level: 'critical' | 'important' | 'standard' | 'info';
type_class: 'action_needed' | 'prevented_issue' | 'trend_warning' | 'escalation' | 'information';
orchestrator_context?: {
already_addressed?: boolean;
action_type?: string;
entity_id?: string;
delivery_date?: string;
reasoning?: string;
};
business_impact?: {
financial_impact_eur?: number;
affected_orders?: number;
affected_products?: string[];
stockout_risk_hours?: number;
customer_satisfaction_impact?: 'high' | 'medium' | 'low';
};
urgency_context?: {
deadline?: string;
time_until_consequence_hours?: number;
can_wait_until_tomorrow?: boolean;
auto_action_countdown_seconds?: number;
};
user_agency?: {
can_user_fix?: boolean;
requires_external_party?: boolean;
external_party_name?: string;
external_party_contact?: string;
};
trend_context?: {
metric_name?: string;
current_value?: number;
baseline_value?: number;
change_percentage?: number;
direction?: 'increasing' | 'decreasing';
significance?: 'high' | 'medium' | 'low';
};
actions?: any[]; // Smart actions
ai_reasoning_summary?: string;
confidence_score?: number;
placement?: string[];
}
const STORAGE_KEY = 'bakery-notifications';
const SNOOZE_STORAGE_KEY = 'bakery-snoozed-alerts';
/**
* Clear all notification data from sessionStorage
* This is typically called during logout to ensure notifications don't persist across sessions
*/
export const clearNotificationStorage = () => {
try {
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(SNOOZE_STORAGE_KEY);
} catch (error) {
console.warn('Failed to clear notification storage:', error);
}
};
const loadNotificationsFromStorage = (): NotificationData[] => {
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
// Clean up old alerts (older than 24 hours)
// This prevents accumulation of stale alerts in sessionStorage
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
const recentAlerts = parsed.filter(n => {
const alertTime = new Date(n.timestamp).getTime();
return alertTime > oneDayAgo;
});
// If we filtered out alerts, update sessionStorage
if (recentAlerts.length !== parsed.length) {
console.log(`Cleaned ${parsed.length - recentAlerts.length} old alerts from sessionStorage`);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(recentAlerts));
}
return recentAlerts;
}
}
} catch (error) {
console.warn('Failed to load notifications from sessionStorage:', error);
}
return [];
};
const saveNotificationsToStorage = (notifications: NotificationData[]) => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
} catch (error) {
console.warn('Failed to save notifications to sessionStorage:', error);
}
};
const loadSnoozedAlertsFromStorage = (): Map<string, SnoozedAlert> => {
try {
const stored = sessionStorage.getItem(SNOOZE_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
const map = new Map<string, SnoozedAlert>();
Object.entries(parsed).forEach(([key, value]) => {
const snoozed = value as SnoozedAlert;
// Only add if not expired
if (snoozed.until > Date.now()) {
map.set(key, snoozed);
}
});
return map;
}
} catch (error) {
console.warn('Failed to load snoozed alerts from sessionStorage:', error);
}
return new Map();
};
const saveSnoozedAlertsToStorage = (snoozedAlerts: Map<string, SnoozedAlert>) => {
try {
const obj: Record<string, SnoozedAlert> = {};
snoozedAlerts.forEach((value, key) => {
// Only save if not expired
if (value.until > Date.now()) {
obj[key] = value;
}
});
sessionStorage.setItem(SNOOZE_STORAGE_KEY, JSON.stringify(obj));
} catch (error) {
console.warn('Failed to save snoozed alerts to sessionStorage:', error);
}
};
/**
* useNotifications - Hook for managing real-time notifications and alerts
*
* Features:
* - SSE connection for real-time alerts
* - sessionStorage persistence with auto-cleanup (alerts >24h are removed on load)
* - Snooze functionality with expiration tracking
* - Bulk operations (mark multiple as read, remove, snooze)
*
* Note: Notifications are session-only and cleared when the browser tab/window closes
* or when the user logs out. Alerts older than 24 hours are automatically cleaned
* on load to prevent accumulation of stale data.
*/
export const useNotifications = () => {
const [notifications, setNotifications] = useState<NotificationData[]>(() => loadNotificationsFromStorage());
const [snoozedAlerts, setSnoozedAlerts] = useState<Map<string, SnoozedAlert>>(() => loadSnoozedAlertsFromStorage());
const [unreadCount, setUnreadCount] = useState(() => {
const stored = loadNotificationsFromStorage();
return stored.filter(n => !n.read).length;
});
const { addEventListener, isConnected } = useSSE();
// Save to localStorage whenever notifications change
useEffect(() => {
saveNotificationsToStorage(notifications);
}, [notifications]);
// Save snoozed alerts to localStorage
useEffect(() => {
saveSnoozedAlertsToStorage(snoozedAlerts);
}, [snoozedAlerts]);
// Clean up expired snoozed alerts periodically
useEffect(() => {
const cleanupInterval = setInterval(() => {
setSnoozedAlerts(prev => {
const updated = new Map(prev);
let hasChanges = false;
updated.forEach((value, key) => {
if (value.until <= Date.now()) {
updated.delete(key);
hasChanges = true;
}
});
return hasChanges ? updated : prev;
});
}, 60 * 1000); // Check every minute
return () => clearInterval(cleanupInterval);
}, []);
useEffect(() => {
// Listen for initial_items event (existing notifications)
const removeInitialListener = addEventListener('initial_items', (data: any[]) => {
if (Array.isArray(data) && data.length > 0) {
const initialNotifications: NotificationData[] = data.map(item => ({
id: item.id,
item_type: item.item_type,
title: item.title,
message: item.message,
timestamp: item.timestamp || new Date().toISOString(),
read: false,
metadata: item.metadata,
// Enriched fields (REQUIRED)
priority_score: item.priority_score || 50,
priority_level: item.priority_level || 'standard',
type_class: item.type_class || 'information',
orchestrator_context: item.orchestrator_context,
business_impact: item.business_impact,
urgency_context: item.urgency_context,
user_agency: item.user_agency,
trend_context: item.trend_context,
actions: item.actions || [],
ai_reasoning_summary: item.ai_reasoning_summary,
confidence_score: item.confidence_score,
placement: item.placement || ['notification_panel'],
}));
setNotifications(prev => {
// Merge initial items with existing notifications, avoiding duplicates
const existingIds = new Set(prev.map(n => n.id));
const newNotifications = initialNotifications.filter(n => !existingIds.has(n.id));
const combined = [...newNotifications, ...prev].slice(0, 50);
return combined;
});
setUnreadCount(prev => {
const newUnreadCount = initialNotifications.filter(n => !n.read).length;
return prev + newUnreadCount;
});
}
});
// Listen for alert events (enriched alerts from alert-processor)
const removeAlertListener = addEventListener('alert', (data: any) => {
const notification: NotificationData = {
id: data.id,
item_type: 'alert',
title: data.title,
message: data.message,
timestamp: data.timestamp || new Date().toISOString(),
read: false,
metadata: data.metadata,
// Enriched alert fields (REQUIRED)
priority_score: data.priority_score || 50,
priority_level: data.priority_level || 'standard',
type_class: data.type_class || 'action_needed',
orchestrator_context: data.orchestrator_context,
business_impact: data.business_impact,
urgency_context: data.urgency_context,
user_agency: data.user_agency,
trend_context: data.trend_context,
actions: data.actions || [],
ai_reasoning_summary: data.ai_reasoning_summary,
confidence_score: data.confidence_score,
placement: data.placement || ['notification_panel'],
};
setNotifications(prev => {
// Check if notification already exists
const exists = prev.some(n => n.id === notification.id);
if (exists) return prev;
const newNotifications = [notification, ...prev].slice(0, 50); // Keep last 50 notifications
// Only update state if there's an actual change
return newNotifications;
});
setUnreadCount(prev => prev + 1);
});
// Listen for recommendation events
const removeRecommendationListener = addEventListener('recommendation', (data: any) => {
const notification: NotificationData = {
id: data.id,
item_type: 'recommendation',
title: data.title,
message: data.message,
timestamp: data.timestamp || new Date().toISOString(),
read: false,
metadata: data.metadata,
// Recommendations use info priority by default
priority_score: data.priority_score || 40,
priority_level: data.priority_level || 'info',
type_class: data.type_class || 'information',
orchestrator_context: data.orchestrator_context,
business_impact: data.business_impact,
urgency_context: data.urgency_context,
user_agency: data.user_agency,
trend_context: data.trend_context,
actions: data.actions || [],
ai_reasoning_summary: data.ai_reasoning_summary,
confidence_score: data.confidence_score,
placement: data.placement || ['notification_panel'],
};
setNotifications(prev => {
// Check if notification already exists
const exists = prev.some(n => n.id === notification.id);
if (exists) return prev;
const newNotifications = [notification, ...prev].slice(0, 50); // Keep last 50 notifications
// Only update state if there's an actual change
return newNotifications;
});
setUnreadCount(prev => prev + 1);
});
return () => {
removeInitialListener();
removeAlertListener();
removeRecommendationListener();
};
}, [addEventListener]);
const markAsRead = useCallback((notificationId: string) => {
setNotifications(prev =>
prev.map(notification =>
notification.id === notificationId
? { ...notification, read: true }
: notification
)
);
setUnreadCount(prev => Math.max(0, prev - 1));
}, []);
const markAllAsRead = useCallback(() => {
setNotifications(prev =>
prev.map(notification => ({ ...notification, read: true }))
);
setUnreadCount(0);
}, []);
const removeNotification = useCallback((notificationId: string) => {
// Use functional state update to avoid dependency on notifications array
setNotifications(prev => {
const notification = prev.find(n => n.id === notificationId);
// Update unread count if necessary
if (notification && !notification.read) {
setUnreadCount(curr => Math.max(0, curr - 1));
}
return prev.filter(n => n.id !== notificationId);
});
// Also remove from snoozed if present
setSnoozedAlerts(prev => {
const updated = new Map(prev);
updated.delete(notificationId);
return updated;
});
}, []); // Fixed: No dependencies needed with functional updates
const clearAllNotifications = useCallback(() => {
setNotifications([]);
setUnreadCount(0);
}, []);
// Snooze an alert
const snoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number, reason?: string) => {
const until = calculateSnoozeUntil(duration);
setSnoozedAlerts(prev => {
const updated = new Map(prev);
updated.set(alertId, { alertId, until, reason });
return updated;
});
}, []);
// Unsnooze an alert
const unsnoozeAlert = useCallback((alertId: string) => {
setSnoozedAlerts(prev => {
const updated = new Map(prev);
updated.delete(alertId);
return updated;
});
}, []);
// Check if alert is snoozed
// Note: This function has side effects (removes expired snoozes) but we need to avoid
// depending on snoozedAlerts to prevent callback recreation. We use a ref to access
// the latest snoozedAlerts value without triggering re-renders.
const snoozedAlertsRef = useRef(snoozedAlerts);
useEffect(() => {
snoozedAlertsRef.current = snoozedAlerts;
}, [snoozedAlerts]);
const isAlertSnoozed = useCallback((alertId: string): boolean => {
const snoozed = snoozedAlertsRef.current.get(alertId);
if (!snoozed) {
return false;
}
if (snoozed.until <= Date.now()) {
// Expired, remove it
setSnoozedAlerts(prev => {
const updated = new Map(prev);
updated.delete(alertId);
return updated;
});
return false;
}
return true;
}, []); // Fixed: No dependencies, uses ref for latest state
// Get snoozed alerts that are active
const activeSnoozedAlerts = useMemo(() => {
const active = new Map<string, SnoozedAlert>();
snoozedAlerts.forEach((value, key) => {
if (value.until > Date.now()) {
active.set(key, value);
}
});
return active;
}, [snoozedAlerts]);
// Bulk operations
const markMultipleAsRead = useCallback((notificationIds: string[]) => {
const idsSet = new Set(notificationIds);
// Use functional state update to avoid dependency on notifications array
setNotifications(prev => {
// Calculate unread count that will be marked as read
const unreadToMark = prev.filter(n => idsSet.has(n.id) && !n.read).length;
// Update unread count
if (unreadToMark > 0) {
setUnreadCount(curr => Math.max(0, curr - unreadToMark));
}
// Return updated notifications
return prev.map(notification =>
idsSet.has(notification.id)
? { ...notification, read: true }
: notification
);
});
}, []); // Fixed: No dependencies needed with functional updates
const removeMultiple = useCallback((notificationIds: string[]) => {
const idsSet = new Set(notificationIds);
// Use functional state update to avoid dependency on notifications array
setNotifications(prev => {
// Calculate unread count that will be removed
const unreadToRemove = prev.filter(n => idsSet.has(n.id) && !n.read).length;
// Update unread count
if (unreadToRemove > 0) {
setUnreadCount(curr => Math.max(0, curr - unreadToRemove));
}
// Return filtered notifications
return prev.filter(n => !idsSet.has(n.id));
});
// Also remove from snoozed
setSnoozedAlerts(prev => {
const updated = new Map(prev);
notificationIds.forEach(id => updated.delete(id));
return updated;
});
}, []); // Fixed: No dependencies needed with functional updates
const snoozeMultiple = useCallback((alertIds: string[], duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
const until = calculateSnoozeUntil(duration);
setSnoozedAlerts(prev => {
const updated = new Map(prev);
alertIds.forEach(id => {
updated.set(id, { alertId: id, until });
});
return updated;
});
}, []);
return {
notifications,
unreadCount,
isConnected,
markAsRead,
markAllAsRead,
removeNotification,
clearAll: clearAllNotifications,
snoozeAlert,
unsnoozeAlert,
isAlertSnoozed,
snoozedAlerts: activeSnoozedAlerts,
markMultipleAsRead,
removeMultiple,
snoozeMultiple,
};
};

View File

@@ -1,188 +0,0 @@
/**
* Hook for translating reasoning_data structures
*
* Handles translation of structured backend reasoning data into
* user-friendly, multilingual text for the JTBD dashboard.
*/
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export interface ReasoningData {
type: string;
parameters: Record<string, any>;
consequence?: {
type: string;
severity?: string;
impact_days?: number;
[key: string]: any;
};
urgency?: {
level?: string;
[key: string]: any;
};
metadata?: Record<string, any>;
}
export function useReasoningTranslation() {
const { t } = useTranslation('reasoning');
/**
* Translate purchase order reasoning
* IMPORTANT: Always returns a string, never undefined
*/
const translatePOReasonng = (reasoningData: ReasoningData): string => {
if (!reasoningData || !reasoningData.type) {
const fallback = t('purchaseOrder.low_stock_detection', {
supplier_name: 'Unknown',
product_names_joined: 'Items',
days_until_stockout: 7,
defaultValue: 'Purchase order required for inventory replenishment'
});
return String(fallback || 'Purchase order required');
}
const { type, parameters } = reasoningData;
// Join product names if array
const params = {
...parameters,
product_names_joined: Array.isArray(parameters.product_names)
? parameters.product_names.join(', ')
: parameters.product_names || 'Items',
defaultValue: `Purchase order: ${type}`
};
const result = t(`purchaseOrder.${type}`, params);
return String(result || `Purchase order: ${type}`);
};
/**
* Translate production batch reasoning
* IMPORTANT: Always returns a string, never undefined
*/
const translateBatchReasoning = (reasoningData: ReasoningData): string => {
if (!reasoningData || !reasoningData.type) {
const fallback = t('productionBatch.forecast_demand', {
product_name: 'Product',
predicted_demand: 0,
current_stock: 0,
confidence_score: 85,
defaultValue: 'Production batch scheduled based on demand forecast'
});
return String(fallback || 'Production batch scheduled');
}
const { type, parameters } = reasoningData;
const params = {
...parameters,
defaultValue: `Production batch: ${type}`
};
const result = t(`productionBatch.${type}`, params);
return String(result || `Production batch: ${type}`);
};
/**
* Translate consequence text
* IMPORTANT: Always returns a string, never undefined
*/
const translateConsequence = (consequenceData?: any): string => {
if (!consequenceData || !consequenceData.type) {
return '';
}
const params = {
...consequenceData,
affected_products_joined: Array.isArray(consequenceData.affected_products)
? consequenceData.affected_products.join(', ')
: consequenceData.affected_products || 'products',
defaultValue: `Impact: ${consequenceData.type}`
};
const result = t(`consequence.${consequenceData.type}`, params);
return String(result || '');
};
/**
* Translate severity level
* IMPORTANT: Always returns a string, never undefined
*/
const translateSeverity = (severity?: string): string => {
if (!severity) return '';
const result = t(`severity.${severity}`, { defaultValue: severity });
return String(result || '');
};
/**
* Translate trigger source
* IMPORTANT: Always returns a string, never undefined
*/
const translateTrigger = (trigger?: string): string => {
if (!trigger) return '';
const result = t(`triggers.${trigger}`, { defaultValue: trigger });
return String(result || '');
};
/**
* Translate error code
* IMPORTANT: Always returns a string, never undefined
*/
const translateError = (errorCode: string): string => {
const result = t(`errors.${errorCode}`, { defaultValue: errorCode });
return String(result || errorCode);
};
return {
translatePOReasonng,
translateBatchReasoning,
translateConsequence,
translateSeverity,
translateTrigger,
translateError,
t, // Expose the raw t function for direct access
};
}
/**
* Format reasoning data for display
* Combines reasoning and consequence into a cohesive message
*/
export function useReasoningFormatter() {
const translation = useReasoningTranslation();
const formatPOAction = useCallback((reasoningData?: ReasoningData) => {
if (!reasoningData) {
return {
reasoning: String(translation.translatePOReasonng({} as ReasoningData) || 'Purchase order required'),
consequence: String(''),
severity: String('')
};
}
const reasoning = String(translation.translatePOReasonng(reasoningData) || 'Purchase order required');
const consequence = String(translation.translateConsequence(reasoningData.consequence) || '');
const severity = String(translation.translateSeverity(reasoningData.consequence?.severity) || '');
return { reasoning, consequence, severity };
}, [translation]);
const formatBatchAction = useCallback((reasoningData?: ReasoningData) => {
if (!reasoningData) {
return {
reasoning: String(translation.translateBatchReasoning({} as ReasoningData) || 'Production batch scheduled'),
urgency: String('normal')
};
}
const reasoning = String(translation.translateBatchReasoning(reasoningData) || 'Production batch scheduled');
const urgency = String(reasoningData.urgency?.level || 'normal');
return { reasoning, urgency };
}, [translation]);
return {
formatPOAction,
formatBatchAction,
...translation
};
}

View File

@@ -16,8 +16,8 @@ import type {
Recommendation,
EventDomain,
UseRecommendationsConfig,
} from '../types/events';
import { isRecommendation } from '../types/events';
} from '../api/types/events';
import { isRecommendation } from '../api/types/events';
interface UseRecommendationsReturn {
recommendations: Recommendation[];

View File

@@ -12,8 +12,8 @@
import { useContext, useEffect, useState, useCallback } from 'react';
import { SSEContext } from '../contexts/SSEContext';
import type { Event, Alert, Notification, Recommendation } from '../types/events';
import { convertLegacyAlert } from '../types/events';
import type { Event, Alert, Notification, Recommendation } from '../api/types/events';
import { convertLegacyAlert } from '../api/types/events';
interface UseSSEConfig {
channels?: string[];

View File

@@ -9,6 +9,7 @@ import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { subscriptionService } from '@/api/services/subscription';
import type { SubscriptionTier } from '@/api/types/subscription';
import { useTenantId } from './useTenantId';
// Type definitions
interface UsageMetric {
@@ -68,20 +69,9 @@ interface ForecastData {
}>;
}
// Helper to get current tenant ID (replace with your auth logic)
const getCurrentTenantId = (): string => {
// TODO: Replace with your actual tenant ID retrieval logic
// Example: return useAuth().currentTenant.id;
const pathParts = window.location.pathname.split('/');
const tenantIndex = pathParts.indexOf('tenants');
if (tenantIndex !== -1 && pathParts[tenantIndex + 1]) {
return pathParts[tenantIndex + 1];
}
return localStorage.getItem('currentTenantId') || '';
};
export const useSubscription = () => {
const tenantId = getCurrentTenantId();
// Use centralized tenant ID hook from tenant store
const tenantId = useTenantId();
// Fetch current subscription
const {

View File

@@ -13,6 +13,16 @@ export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
const filterRoutesBySubscription = (routeList: RouteConfig[]): RouteConfig[] => {
return routeList.reduce((filtered, route) => {
// Check if route requires subscription features
if (route.requiredSubscriptionFeature) {
// Special case for distribution feature which requires enterprise
if (route.requiredSubscriptionFeature === 'distribution') {
const isEnterprise = subscriptionInfo.plan === 'enterprise';
if (!isEnterprise) {
return filtered; // Skip this route
}
}
}
if (route.requiredAnalyticsLevel) {
const hasAccess = canAccessAnalytics(route.requiredAnalyticsLevel);
if (!hasAccess) {
@@ -38,7 +48,7 @@ export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
if (route.children) {
if (filteredRoute.children && filteredRoute.children.length > 0) {
filtered.push(filteredRoute);
} else if (!route.requiredAnalyticsLevel) {
} else if (!route.requiredAnalyticsLevel && !route.requiredSubscriptionFeature) {
// Include parent without children if it doesn't require subscription
filtered.push({ ...route, children: [] });
}
@@ -54,6 +64,7 @@ export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
// While loading, show basic routes only
return routes.filter(route =>
!route.requiredAnalyticsLevel &&
!route.requiredSubscriptionFeature &&
route.path !== '/app/analytics'
);
}

View File

@@ -24,6 +24,7 @@
"actions": {
"approve_po": "Approve €{amount} order",
"reject_po": "Reject order",
"view_po_details": "View details",
"call_supplier": "Call {supplier} ({phone})",
"see_reasoning": "See full reasoning",
"complete_receipt": "Complete stock receipt",

View File

@@ -589,6 +589,54 @@
"conclusion_title": "Conclusion",
"conclusion": "GDPR is not optional. But it's not complicated either if you use the right tools.\n\n**Your priority:** software that takes GDPR seriously, so you can focus on making bread."
}
},
"saas_future": {
"title": "Why SaaS is the Future of Bakery Management",
"excerpt": "Cloud software is transforming how bakeries operate. Discover why moving to a SaaS model can save you money and improve efficiency.",
"author": "Tech Team",
"tags": {
"saas": "SaaS",
"cloud": "cloud",
"digital_transformation": "digital transformation",
"efficiency": "efficiency"
},
"content": {
"intro": "The bakery industry is traditional, but management doesn't have to be. Software as a Service (SaaS) is changing the game.",
"benefits_title": "Key Benefits of SaaS",
"benefit_1_title": "1. Accessibility",
"benefit_1_desc": "Access your bakery's data from anywhere, anytime. Whether you're at the shop, at home, or on vacation.",
"benefit_2_title": "2. Cost-Effectiveness",
"benefit_2_desc": "No heavy upfront investment in servers or hardware. Pay a monthly subscription that scales with your business.",
"benefit_3_title": "3. Automatic Updates",
"benefit_3_desc": "Always use the latest version. New features and security patches are applied automatically without downtime.",
"security_title": "Is it Secure?",
"security_desc": "**Yes.** Modern SaaS providers use bank-level encryption and regular backups to ensure your data is safe.",
"conclusion": "Embracing SaaS is not just about technology; it's about giving yourself the freedom to focus on what matters: baking great products."
}
},
"inventory_mastery": {
"title": "Mastering Inventory: The Key to Profitability",
"excerpt": "Effective inventory management is the backbone of a profitable bakery. Learn how to control costs and reduce waste.",
"author": "Operations Team",
"tags": {
"inventory": "inventory",
"profitability": "profitability",
"cost_control": "cost control",
"waste_reduction": "waste reduction"
},
"content": {
"intro": "Inventory is money sitting on your shelves. Managing it effectively is crucial for your bottom line.",
"strategies_title": "Strategies for Success",
"strategy_1_title": "1. FIFO (First In, First Out)",
"strategy_1_desc": "Ensure older stock is used before newer stock to prevent spoilage. This is essential for perishable ingredients.",
"strategy_2_title": "2. Regular Audits",
"strategy_2_desc": "Conduct regular physical counts to verify your system records match reality. Spot discrepancies early.",
"strategy_3_title": "3. Par Levels",
"strategy_3_desc": "Set minimum stock levels for each item. When stock dips below, it's time to reorder.",
"technology_role_title": "The Role of Technology",
"technology_role_desc": "Modern bakery management systems can automate these processes, tracking usage in real-time and generating smart order lists.",
"conclusion": "Mastering inventory takes discipline, but the reward is a leaner, more profitable business with less waste."
}
}
}
}
}

View File

@@ -9,6 +9,8 @@
"orders": "Orders",
"procurement": "Procurement",
"pos": "Point of Sale",
"distribution": "Distribution",
"central_baker": "Central Baker",
"analytics": "Analytics",
"production_analytics": "Production Dashboard",
"procurement_analytics": "Procurement Dashboard",
@@ -436,4 +438,4 @@
"about": "About",
"contact": "Contact"
}
}
}

View File

@@ -315,7 +315,13 @@
"overdue": "Overdue",
"pending_approvals": "Pending Approvals",
"whats_next": "What's Next",
"starts_at": "starts at"
"starts_at": "starts at",
"overdue_deliveries": "Overdue Deliveries",
"pending_deliveries": "Pending Deliveries",
"overdue_label": "overdue",
"arriving_in": "arriving in",
"more_items": "more items",
"more_deliveries": "more deliveries"
},
"intelligent_system": {
"title": "Intelligent System Summary",

View File

@@ -1,4 +1,7 @@
{
"orchestration": {
"daily_summary": "{purchase_orders_count, plural, =0 {} =1 {Created 1 purchase order} other {Created {purchase_orders_count} purchase orders}}{purchase_orders_count, plural, =0 {} other { and }}{production_batches_count, plural, =0 {no production batches} =1 {scheduled 1 production batch} other {scheduled {production_batches_count} production batches}}. {critical_items_count, plural, =0 {All items in stock.} =1 {1 critical item needs attention} other {{critical_items_count} critical items need attention}}{total_financial_impact_eur, select, 0 {} other { (€{total_financial_impact_eur} at risk)}}{min_depletion_hours, select, 0 {} other { - {min_depletion_hours}h until stockout}}."
},
"purchaseOrder": {
"low_stock_detection": "Low stock for {supplier_name}. Current stock of {product_names_joined} will run out in {days_until_stockout} days.",
"low_stock_detection_detailed": "{critical_product_count, plural, =1 {{critical_products_0} will deplete in {min_depletion_hours} hours} other {{critical_product_count} critical items running low}}. With {supplier_name}'s {supplier_lead_time_days}-day delivery, we must order {order_urgency, select, critical {IMMEDIATELY} urgent {TODAY} important {soon} other {now}} to prevent {affected_batches_count, plural, =0 {production delays} =1 {disruption to {affected_batches_0}} other {{affected_batches_count} batch disruptions}}{potential_loss_eur, select, 0 {} other { (€{potential_loss_eur} at risk)}}.",
@@ -85,7 +88,9 @@
},
"action_queue": {
"title": "What Needs Your Attention",
"why_needed": "Why this is needed:",
"what_happened": "What happened",
"why_needed": "Why this is needed",
"what_to_do": "What you should do",
"what_if_not": "What happens if I don't do this?",
"estimated_time": "Estimated time",
"all_caught_up": "All caught up!",

View File

@@ -24,6 +24,7 @@
"actions": {
"approve_po": "Aprobar pedido €{amount}",
"reject_po": "Rechazar pedido",
"view_po_details": "Ver detalles",
"call_supplier": "Llamar a {supplier} ({phone})",
"see_reasoning": "Ver razonamiento completo",
"complete_receipt": "Completar recepción de stock",
@@ -46,6 +47,7 @@
"last_run": "Última ejecución",
"what_ai_did": "Lo que hizo la IA por ti"
},
"ai_reasoning_label": "Razonamiento de IA",
"no_reasoning_available": "No hay razonamiento disponible",
"metrics": {
"hours": "{count, plural, =1 {# hora} other {# horas}}",

View File

@@ -589,6 +589,54 @@
"conclusion_title": "Conclusión",
"conclusion": "El RGPD no es opcional. Pero tampoco es complicado si usas las herramientas correctas.\n\n**Tu prioridad:** software que tome el GDPR en serio, para que tú puedas enfocarte en hacer pan."
}
},
"saas_future": {
"title": "Por qué el SaaS es el Futuro de la Gestión de Panaderías",
"excerpt": "El software en la nube está transformando cómo operan las panaderías. Descubre por qué moverte a un modelo SaaS puede ahorrarte dinero y mejorar la eficiencia.",
"author": "Equipo Técnico",
"tags": {
"saas": "SaaS",
"cloud": "nube",
"digital_transformation": "transformación digital",
"efficiency": "eficiencia"
},
"content": {
"intro": "La industria de la panadería es tradicional, pero la gestión no tiene por qué serlo. El Software como Servicio (SaaS) está cambiando el juego.",
"benefits_title": "Beneficios Clave del SaaS",
"benefit_1_title": "1. Accesibilidad",
"benefit_1_desc": "Accede a los datos de tu panadería desde cualquier lugar, en cualquier momento. Ya sea en la tienda, en casa o de vacaciones.",
"benefit_2_title": "2. Rentabilidad",
"benefit_2_desc": "Sin grandes inversiones iniciales en servidores o hardware. Paga una suscripción mensual que escala con tu negocio.",
"benefit_3_title": "3. Actualizaciones Automáticas",
"benefit_3_desc": "Usa siempre la última versión. Las nuevas funciones y parches de seguridad se aplican automáticamente sin tiempo de inactividad.",
"security_title": "¿Es Seguro?",
"security_desc": "**Sí.** Los proveedores modernos de SaaS utilizan cifrado de nivel bancario y copias de seguridad periódicas para garantizar que tus datos estén seguros.",
"conclusion": "Adoptar SaaS no se trata solo de tecnología; se trata de darte la libertad de concentrarte en lo que importa: hornear grandes productos."
}
},
"inventory_mastery": {
"title": "Dominando el Inventario: La Clave de la Rentabilidad",
"excerpt": "La gestión eficaz del inventario es la columna vertebral de una panadería rentable. Aprende a controlar costos y reducir el desperdicio.",
"author": "Equipo de Operaciones",
"tags": {
"inventory": "inventario",
"profitability": "rentabilidad",
"cost_control": "control de costos",
"waste_reduction": "reducción de desperdicios"
},
"content": {
"intro": "El inventario es dinero en tus estantes. Gestionarlo de manera efectiva es crucial para tu balance final.",
"strategies_title": "Estrategias para el Éxito",
"strategy_1_title": "1. FIFO (Primero en Entrar, Primero en Salir)",
"strategy_1_desc": "Asegúrate de que el stock más antiguo se use antes que el nuevo para evitar el deterioro. Esto es esencial para ingredientes perecederos.",
"strategy_2_title": "2. Auditorías Regulares",
"strategy_2_desc": "Realiza recuentos físicos regulares para verificar que los registros de tu sistema coincidan con la realidad. Detecta discrepancias temprano.",
"strategy_3_title": "3. Niveles de Par",
"strategy_3_desc": "Establece niveles mínimos de stock para cada artículo. Cuando el stock cae por debajo, es hora de reordenar.",
"technology_role_title": "El Papel de la Tecnología",
"technology_role_desc": "Los sistemas modernos de gestión de panaderías pueden automatizar estos procesos, rastreando el uso en tiempo real y generando listas de pedidos inteligentes.",
"conclusion": "Dominar el inventario requiere disciplina, pero la recompensa es un negocio más ágil y rentable con menos desperdicio."
}
}
}
}
}

View File

@@ -9,6 +9,8 @@
"orders": "Pedidos",
"procurement": "Compras",
"pos": "Punto de Venta",
"distribution": "Distribución",
"central_baker": "Central Baker",
"analytics": "Análisis",
"production_analytics": "Dashboard de Producción",
"procurement_analytics": "Dashboard de Compras",
@@ -458,4 +460,4 @@
"about": "Nosotros",
"contact": "Contacto"
}
}
}

View File

@@ -249,7 +249,7 @@
}
},
"setup_banner": {
"title": "{{count}} paso(s) más para desbloquear todas las funciones",
"title": "{count} paso(s) más para desbloquear todas las funciones",
"recommended": "(recomendado)",
"added": "agregado(s)",
"recommended_count": "Recomendado",
@@ -364,7 +364,13 @@
"overdue": "Atrasado",
"pending_approvals": "Aprobaciones Pendientes",
"whats_next": "Qué Sigue",
"starts_at": "comienza a las"
"starts_at": "comienza a las",
"overdue_deliveries": "Entregas Atrasadas",
"pending_deliveries": "Entregas Pendientes",
"overdue_label": "de retraso",
"arriving_in": "llega en",
"more_items": "artículos más",
"more_deliveries": "entregas más"
},
"intelligent_system": {
"title": "Resumen del Sistema Inteligente",

View File

@@ -1,4 +1,7 @@
{
"orchestration": {
"daily_summary": "{purchase_orders_count, plural, =0 {} =1 {Creé 1 orden de compra} other {Creé {purchase_orders_count} órdenes de compra}}{purchase_orders_count, plural, =0 {} other { y }}{production_batches_count, plural, =0 {ningún lote de producción} =1 {programé 1 lote de producción} other {programé {production_batches_count} lotes de producción}}. {critical_items_count, plural, =0 {Todo en stock.} =1 {1 artículo crítico necesita atención} other {{critical_items_count} artículos críticos necesitan atención}}{total_financial_impact_eur, select, 0 {} other { (€{total_financial_impact_eur} en riesgo)}}{min_depletion_hours, select, 0 {} other { - {min_depletion_hours}h hasta agotamiento}}."
},
"purchaseOrder": {
"low_stock_detection": "Stock bajo para {supplier_name}. El stock actual de {product_names_joined} se agotará en {days_until_stockout} días.",
"low_stock_detection_detailed": "{critical_product_count, plural, =1 {{critical_products_0} se agotará en {min_depletion_hours} horas} other {{critical_product_count} productos críticos escasos}}. Con entrega de {supplier_lead_time_days} días de {supplier_name}, debemos pedir {order_urgency, select, critical {INMEDIATAMENTE} urgent {HOY} important {pronto} other {ahora}} para evitar {affected_batches_count, plural, =0 {retrasos en producción} =1 {interrupción del lote {affected_batches_0}} other {interrupción de {affected_batches_count} lotes}}{potential_loss_eur, select, 0 {} other { (€{potential_loss_eur} en riesgo)}}.",
@@ -85,7 +88,9 @@
},
"action_queue": {
"title": "Qué Necesita Tu Atención",
"why_needed": "Por qué es necesario esto:",
"what_happened": "Qué pasó",
"why_needed": "Por qué es necesario",
"what_to_do": "Qué debes hacer",
"what_if_not": "¿Qué pasa si no hago esto?",
"estimated_time": "Tiempo estimado",
"all_caught_up": "¡Todo al día!",

View File

@@ -24,6 +24,7 @@
"actions": {
"approve_po": "Onartu €{amount} eskaera",
"reject_po": "Baztertu eskaera",
"view_po_details": "Ikusi xehetasunak",
"call_supplier": "Deitu {supplier}ri ({phone})",
"see_reasoning": "Ikusi arrazoibide osoa",
"complete_receipt": "Osatu stockaren harrera",

View File

@@ -589,6 +589,54 @@
"conclusion_title": "Ondorioa",
"conclusion": "RGPDa ez da aukerakoa. Baina ez da ere korapilatsua tresna egokiak erabiltzen badituzu.\n\n**Zure lehentasuna:** GDPRa benetan hartzen duen softwarea, zuk ogia egitean zentratzen ahal izateko."
}
},
"saas_future": {
"title": "Zergatik SaaS da Okindegi Kudeaketaren Etorkizuna",
"excerpt": "Hodeiko softwarea okindegien funtzionamendua eraldatzen ari da. Aurkitu zergatik SaaS eredu batera pasatzeak dirua aurreztu eta eraginkortasuna hobetu dezakeen.",
"author": "Talde Teknikoa",
"tags": {
"saas": "SaaS",
"cloud": "hodeia",
"digital_transformation": "eraldaketa digitala",
"efficiency": "eraginkortasuna"
},
"content": {
"intro": "Okindegi industria tradizionala da, baina kudeaketak ez du zertan izan. Softwarea Zerbitzu gisa (SaaS) jokoa aldatzen ari da.",
"benefits_title": "SaaS-en Onura Nagusiak",
"benefit_1_title": "1. Irisgarritasuna",
"benefit_1_desc": "Sartu zure okindegiko datuetara edonondik, edonoiz. Dendan, etxean edo oporretan zaudela ere.",
"benefit_2_title": "2. Errentagarritasuna",
"benefit_2_desc": "Zerbitzarietan edo hardwarean hasierako inbertsio handirik gabe. Ordaindu zure negozioarekin eskalatzen den hileko harpidetza.",
"benefit_3_title": "3. Eguneratze Automatikoak",
"benefit_3_desc": "Erabili beti azken bertsioa. Funtzio berriak eta segurtasun adabakiak automatikoki aplikatzen dira geldialdirik gabe.",
"security_title": "Segurua al da?",
"security_desc": "**Bai.** SaaS hornitzaile modernoek banku-mailako enkriptatzea eta ohiko segurtasun kopiak erabiltzen dituzte zure datuak seguru daudela bermatzeko.",
"conclusion": "SaaS onartzea ez da soilik teknologiari buruzkoa; garrantzitsua den horretan zentratzeko askatasuna ematea da: produktu bikainak labetzea."
}
},
"inventory_mastery": {
"title": "Inbentarioa Dominatzen: Errentagarritasunaren Gakoa",
"excerpt": "Inbentario kudeaketa eraginkorra okindegi errentagarri baten bizkarrezurra da. Ikasi kostuak kontrolatzen eta hondakina murrizten.",
"author": "Eragiketa Taldea",
"tags": {
"inventory": "inbentarioa",
"profitability": "errentagarritasuna",
"cost_control": "kostu kontrola",
"waste_reduction": "hondakin murrizketa"
},
"content": {
"intro": "Inbentarioa zure apaletan eserita dagoen dirua da. Eraginkorki kudeatzea ezinbestekoa da zure emaitzetarako.",
"strategies_title": "Arrakastarako Estrategiak",
"strategy_1_title": "1. FIFO (Lehena Sartzen, Lehena Irten)",
"strategy_1_desc": "Ziurtatu stock zaharragoa berria baino lehen erabiltzen dela hondatzea saihesteko. Hau ezinbestekoa da osagai galkorrentzat.",
"strategy_2_title": "2. Ohiko Auditoretzak",
"strategy_2_desc": "Egin zenbaketa fisiko erregularrak zure sistemako erregistroak errealitatearekin bat datozela egiaztatzeko. Detektatu desadostasunak goiz.",
"strategy_3_title": "3. Par Mailak",
"strategy_3_desc": "Ezarri gutxieneko stock mailak artikulu bakoitzerako. Stocka azpitik jaisten denean, berriro eskatzeko ordua da.",
"technology_role_title": "Teknologiaren Papera",
"technology_role_desc": "Okindegi kudeaketa sistema modernoek prozesu hauek automatizatu ditzakete, erabilera denbora errealean jarraituz eta eskaera zerrenda adimentsuak sortuz.",
"conclusion": "Inbentarioa dominatzeak diziplina eskatzen du, baina saria negozio arinago eta errentagarriagoa da, hondakin gutxiagorekin."
}
}
}
}
}

View File

@@ -85,7 +85,9 @@
},
"action_queue": {
"title": "Zer Behar Du Zure Arreta",
"why_needed": "Zergatik behar da hau:",
"what_happened": "Zer gertatu da",
"why_needed": "Zergatik behar da",
"what_to_do": "Zer egin behar duzu",
"what_if_not": "Zer gertatzen da hau egiten ez badut?",
"estimated_time": "Estimatutako denbora",
"all_caught_up": "Dena egunean!",

View File

@@ -25,12 +25,13 @@ import {
useOrchestrationSummary,
useUnifiedActionQueue,
useProductionTimeline,
useInsights,
useApprovePurchaseOrder,
useStartProductionBatch,
usePauseProductionBatch,
useExecutionProgress,
} from '../../api/hooks/newDashboard';
useDashboardRealtime, // PHASE 3: SSE state sync
useProgressiveDashboard, // PHASE 4: Progressive loading
} from '../../api/hooks/useProfessionalDashboard';
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
import { useIngredients } from '../../api/hooks/inventory';
import { useSuppliers } from '../../api/hooks/suppliers';
@@ -54,7 +55,14 @@ import {
useOrchestrationNotifications,
} from '../../hooks';
export function NewDashboardPage() {
// Import Enterprise Dashboard
import EnterpriseDashboardPage from './EnterpriseDashboardPage';
import { useSubscription } from '../../api/hooks/subscription';
import { SUBSCRIPTION_TIERS } from '../../api/types/subscription';
// Rename the existing component to BakeryDashboard
export function BakeryDashboard() {
const navigate = useNavigate();
const { t } = useTranslation(['dashboard', 'common', 'alerts']);
const { currentTenant } = useTenant();
@@ -75,48 +83,107 @@ export function NewDashboardPage() {
const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view');
// Setup Progress Data
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(tenantId, {}, { enabled: !!tenantId });
const { data: suppliers = [], isLoading: loadingSuppliers } = useSuppliers(tenantId, { enabled: !!tenantId });
const { data: recipes = [], isLoading: loadingRecipes } = useRecipes(tenantId, { enabled: !!tenantId });
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(tenantId, { enabled: !!tenantId });
// Always fetch setup data to determine true progress, but use localStorage as fallback during loading
// PHASE 1 OPTIMIZATION: Only use cached value if we're still waiting for API to respond
const setupProgressFromStorage = useMemo(() => {
try {
const cached = localStorage.getItem(`setup_progress_${tenantId}`);
return cached ? parseInt(cached, 10) : 0;
} catch {
return 0;
}
}, [tenantId]);
// Always fetch the actual data to determine true progress
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(
tenantId,
{},
{ enabled: !!tenantId }
);
const { data: suppliers = [], isLoading: loadingSuppliers } = useSuppliers(
tenantId,
{},
{ enabled: !!tenantId }
);
const { data: recipes = [], isLoading: loadingRecipes } = useRecipes(
tenantId,
{},
{ enabled: !!tenantId }
);
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(
tenantId,
{},
{ enabled: !!tenantId }
);
const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : [];
// Data fetching
// PHASE 4: Progressive data loading for perceived performance boost
const {
data: healthStatus,
isLoading: healthLoading,
refetch: refetchHealth,
} = useBakeryHealthStatus(tenantId);
health: {
data: healthStatus,
isLoading: healthLoading,
refetch: refetchHealth,
},
actionQueue: {
data: actionQueue,
isLoading: actionQueueLoading,
refetch: refetchActionQueue,
},
progress: {
data: executionProgress,
isLoading: executionProgressLoading,
refetch: refetchExecutionProgress,
},
overallLoading,
isReady,
} = useProgressiveDashboard(tenantId);
// Additional hooks not part of progressive loading
const {
data: orchestrationSummary,
isLoading: orchestrationLoading,
refetch: refetchOrchestration,
} = useOrchestrationSummary(tenantId);
const {
data: actionQueue,
isLoading: actionQueueLoading,
refetch: refetchActionQueue,
} = useUnifiedActionQueue(tenantId);
const {
data: executionProgress,
isLoading: executionProgressLoading,
refetch: refetchExecutionProgress,
} = useExecutionProgress(tenantId);
const {
data: productionTimeline,
isLoading: timelineLoading,
refetch: refetchTimeline,
} = useProductionTimeline(tenantId);
const {
data: insights,
isLoading: insightsLoading,
refetch: refetchInsights,
} = useInsights(tenantId);
// Insights functionality removed as it's not needed with new architecture
const insights = undefined;
const insightsLoading = false;
const refetchInsights = () => {};
// PHASE 3: Enable SSE real-time state synchronization
useDashboardRealtime(tenantId);
// PHASE 6: Performance monitoring
useEffect(() => {
const loadTime = performance.now();
console.log(`📊 [Performance] Dashboard loaded in ${loadTime.toFixed(0)}ms`);
// Calculate setup completion status based on stored progress (approximation since actual data may not be loaded yet)
const setupComplete = setupProgressFromStorage >= 100;
if (loadTime > 1000) {
console.warn('⚠️ [Performance] Dashboard load time exceeded target (>1000ms):', {
loadTime: `${loadTime.toFixed(0)}ms`,
target: '1000ms',
setupComplete,
queriesSkipped: setupComplete ? 4 : 0,
});
} else {
console.log('✅ [Performance] Dashboard load time within target:', {
loadTime: `${loadTime.toFixed(0)}ms`,
target: '<1000ms',
setupComplete,
queriesSkipped: setupComplete ? 4 : 0,
});
}
}, [setupProgressFromStorage]); // Include setupProgressFromStorage as dependency
// Real-time event subscriptions for automatic refetching
const { notifications: batchNotifications } = useBatchNotifications();
@@ -212,7 +279,7 @@ export function NewDashboardPage() {
});
if (latestBatchNotificationId &&
latestBatchNotificationId !== prevBatchNotificationsRef.current) {
latestBatchNotificationId !== prevBatchNotificationsRef.current) {
console.log('🔥 [Dashboard] NEW batch notification detected, updating ref and refetching');
prevBatchNotificationsRef.current = latestBatchNotificationId;
const latest = batchNotifications[0];
@@ -237,7 +304,7 @@ export function NewDashboardPage() {
});
if (latestDeliveryNotificationId &&
latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current) {
latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current) {
console.log('🔥 [Dashboard] NEW delivery notification detected, updating ref and refetching');
prevDeliveryNotificationsRef.current = latestDeliveryNotificationId;
const latest = deliveryNotifications[0];
@@ -262,7 +329,7 @@ export function NewDashboardPage() {
});
if (latestOrchestrationNotificationId &&
latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) {
latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) {
console.log('🔥 [Dashboard] NEW orchestration notification detected, updating ref and refetching');
prevOrchestrationNotificationsRef.current = latestOrchestrationNotificationId;
const latest = orchestrationNotifications[0];
@@ -401,6 +468,17 @@ export function NewDashboardPage() {
// Calculate overall progress
const { completedSections, totalSections, progressPercentage, criticalMissing, recommendedMissing } = useMemo(() => {
// If data is still loading, use stored value as fallback to prevent flickering
if (loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality) {
return {
completedSections: 0,
totalSections: 4, // 4 required sections
progressPercentage: setupProgressFromStorage, // Use stored value during loading
criticalMissing: [],
recommendedMissing: [],
};
}
// Guard against undefined or invalid setupSections
if (!setupSections || !Array.isArray(setupSections) || setupSections.length === 0) {
return {
@@ -420,6 +498,13 @@ export function NewDashboardPage() {
const critical = setupSections.filter(s => !s.isComplete && s.id !== 'quality');
const recommended = setupSections.filter(s => s.count < s.recommended);
// PHASE 1 OPTIMIZATION: Cache progress to localStorage for next page load
try {
localStorage.setItem(`setup_progress_${tenantId}`, percentage.toString());
} catch {
// Ignore storage errors
}
return {
completedSections: completed,
totalSections: total,
@@ -427,7 +512,7 @@ export function NewDashboardPage() {
criticalMissing: critical,
recommendedMissing: recommended,
};
}, [setupSections]);
}, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]);
const handleRefreshAll = () => {
refetchHealth();
@@ -547,8 +632,8 @@ export function NewDashboardPage() {
</div>
{/* Setup Flow - Three States */}
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality ? (
/* Loading state */
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality || !isReady ? (
/* Loading state - only show spinner until first priority data (health) is ready */
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
</div>
@@ -570,36 +655,60 @@ export function NewDashboardPage() {
{/* Main Dashboard Layout */}
<div className="space-y-6">
{/* SECTION 1: Glanceable Health Hero (Traffic Light) */}
{/* SECTION 1: Glanceable Health Hero (Traffic Light) - PRIORITY 1 */}
<div data-tour="dashboard-stats">
<GlanceableHealthHero
healthStatus={healthStatus}
loading={healthLoading}
urgentActionCount={actionQueue?.urgentCount || 0}
/>
{healthLoading ? (
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
</div>
) : (
<GlanceableHealthHero
healthStatus={healthStatus!}
loading={false}
urgentActionCount={actionQueue?.urgentCount || 0}
/>
)}
</div>
{/* SECTION 2: What Needs Your Attention (Unified Action Queue) */}
{/* SECTION 2: What Needs Your Attention (Unified Action Queue) - PRIORITY 2 */}
<div data-tour="pending-po-approvals">
<UnifiedActionQueueCard
actionQueue={actionQueue}
loading={actionQueueLoading}
tenantId={tenantId}
/>
{actionQueueLoading ? (
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
<div className="space-y-4">
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-32 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
) : (
<UnifiedActionQueueCard
actionQueue={actionQueue!}
loading={false}
tenantId={tenantId}
/>
)}
</div>
{/* SECTION 3: Execution Progress Tracker (Plan vs Actual) */}
{/* SECTION 3: Execution Progress Tracker (Plan vs Actual) - PRIORITY 3 */}
<div data-tour="execution-progress">
<ExecutionProgressTracker
progress={executionProgress}
loading={executionProgressLoading}
/>
{executionProgressLoading ? (
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
<div className="space-y-4">
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/2"></div>
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
) : (
<ExecutionProgressTracker
progress={executionProgress}
loading={false}
/>
)}
</div>
{/* SECTION 4: Intelligent System Summary - Unified AI Impact & Orchestration */}
<div data-tour="intelligent-system-summary">
<IntelligentSystemSummaryCard
orchestrationSummary={orchestrationSummary}
orchestrationSummary={orchestrationSummary!}
orchestrationLoading={orchestrationLoading}
onWorkflowComplete={handleRefreshAll}
/>
@@ -679,4 +788,30 @@ export function NewDashboardPage() {
);
}
export default NewDashboardPage;
/**
* Main Dashboard Page
* Conditionally renders either the Enterprise Dashboard or the Bakery Dashboard
* based on the user's subscription tier.
*/
export function DashboardPage() {
const { subscriptionInfo } = useSubscription();
const { currentTenant } = useTenant();
const { plan, loading } = subscriptionInfo;
const tenantId = currentTenant?.id;
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
</div>
);
}
if (plan === SUBSCRIPTION_TIERS.ENTERPRISE) {
return <EnterpriseDashboardPage tenantId={tenantId} />;
}
return <BakeryDashboard />;
}
export default DashboardPage;

View File

@@ -5,57 +5,82 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery, useQueries } from '@tanstack/react-query';
import {
useNetworkSummary,
useChildrenPerformance,
useDistributionOverview,
useForecastSummary
} from '../../api/hooks/enterprise';
} from '../../api/hooks/useEnterpriseDashboard';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/Card';
import { Badge } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import {
Users,
ShoppingCart,
TrendingUp,
MapPin,
Truck,
Package,
BarChart3,
Network,
Store,
Activity,
Calendar,
Clock,
CheckCircle,
AlertTriangle,
PackageCheck,
Building2,
DollarSign
ArrowLeft,
ChevronRight
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import { ErrorBoundary } from 'react-error-boundary';
import { apiClient } from '../../api/client/apiClient';
import { useEnterprise } from '../../contexts/EnterpriseContext';
import { useTenant } from '../../stores/tenant.store';
// Components for enterprise dashboard
const NetworkSummaryCards = React.lazy(() => import('../../components/dashboard/NetworkSummaryCards'));
const DistributionMap = React.lazy(() => import('../../components/maps/DistributionMap'));
const PerformanceChart = React.lazy(() => import('../../components/charts/PerformanceChart'));
const EnterpriseDashboardPage = () => {
const { tenantId } = useParams();
interface EnterpriseDashboardPageProps {
tenantId?: string;
}
const EnterpriseDashboardPage: React.FC<EnterpriseDashboardPageProps> = ({ tenantId: propTenantId }) => {
const { tenantId: urlTenantId } = useParams<{ tenantId: string }>();
const tenantId = propTenantId || urlTenantId;
const navigate = useNavigate();
const { t } = useTranslation('dashboard');
const { state: enterpriseState, drillDownToOutlet, returnToNetworkView, enterNetworkView } = useEnterprise();
const { switchTenant } = useTenant();
const [selectedMetric, setSelectedMetric] = useState('sales');
const [selectedPeriod, setSelectedPeriod] = useState(30);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
// Check if tenantId is available at the start
useEffect(() => {
if (!tenantId) {
console.error('No tenant ID available for enterprise dashboard');
navigate('/unauthorized');
}
}, [tenantId, navigate]);
// Initialize enterprise mode on mount
useEffect(() => {
if (tenantId && !enterpriseState.parentTenantId) {
enterNetworkView(tenantId);
}
}, [tenantId, enterpriseState.parentTenantId, enterNetworkView]);
// Check if user has enterprise tier access
useEffect(() => {
const checkAccess = async () => {
if (!tenantId) {
console.error('No tenant ID available for enterprise dashboard');
navigate('/unauthorized');
return;
}
try {
const response = await apiClient.get<{ tenant_type: string }>(`/tenants/${tenantId}`);
@@ -78,6 +103,7 @@ const EnterpriseDashboardPage = () => {
error: networkSummaryError
} = useNetworkSummary(tenantId!, {
refetchInterval: 60000, // Refetch every minute
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Fetch children performance data
@@ -85,7 +111,9 @@ const EnterpriseDashboardPage = () => {
data: childrenPerformance,
isLoading: isChildrenPerformanceLoading,
error: childrenPerformanceError
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod);
} = useChildrenPerformance(tenantId!, selectedMetric, selectedPeriod, {
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Fetch distribution overview data
const {
@@ -94,6 +122,7 @@ const EnterpriseDashboardPage = () => {
error: distributionError
} = useDistributionOverview(tenantId!, selectedDate, {
refetchInterval: 60000, // Refetch every minute
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Fetch enterprise forecast summary
@@ -101,7 +130,36 @@ const EnterpriseDashboardPage = () => {
data: forecastSummary,
isLoading: isForecastLoading,
error: forecastError
} = useForecastSummary(tenantId!);
} = useForecastSummary(tenantId!, 7, {
enabled: !!tenantId, // Only fetch if tenantId is available
});
// Handle outlet drill-down
const handleOutletClick = async (outletId: string, outletName: string) => {
// Calculate network metrics if available
const networkMetrics = childrenPerformance?.rankings ? {
totalSales: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'sales' ? r.metric_value : 0), 0),
totalProduction: 0,
totalInventoryValue: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'inventory_value' ? r.metric_value : 0), 0),
averageSales: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'sales' ? r.metric_value : 0), 0) / childrenPerformance.rankings.length,
averageProduction: 0,
averageInventoryValue: childrenPerformance.rankings.reduce((sum, r) => sum + (selectedMetric === 'inventory_value' ? r.metric_value : 0), 0) / childrenPerformance.rankings.length,
childCount: childrenPerformance.rankings.length
} : undefined;
drillDownToOutlet(outletId, outletName, networkMetrics);
await switchTenant(outletId);
navigate('/app/dashboard');
};
// Handle return to network view
const handleReturnToNetwork = async () => {
if (enterpriseState.parentTenantId) {
returnToNetworkView();
await switchTenant(enterpriseState.parentTenantId);
navigate(`/app/enterprise/${enterpriseState.parentTenantId}`);
}
};
// Error boundary fallback
const ErrorFallback = ({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) => (
@@ -142,18 +200,77 @@ const EnterpriseDashboardPage = () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<div className="p-6 min-h-screen bg-gray-50">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Network className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">
{t('enterprise.network_dashboard')}
</h1>
<div className="px-4 sm:px-6 lg:px-8 py-6 min-h-screen" style={{ backgroundColor: 'var(--bg-secondary)' }}>
{/* Breadcrumb / Return to Network Banner */}
{enterpriseState.selectedOutletId && !enterpriseState.isNetworkView && (
<div className="mb-6 rounded-lg p-4" style={{
backgroundColor: 'var(--color-info-light, #dbeafe)',
borderColor: 'var(--color-info, #3b82f6)',
borderWidth: '1px',
borderStyle: 'solid'
}}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Network className="w-5 h-5" style={{ color: 'var(--color-info)' }} />
<div className="flex items-center gap-2 text-sm">
<span className="font-medium" style={{ color: 'var(--color-info)' }}>Network Overview</span>
<ChevronRight className="w-4 h-4" style={{ color: 'var(--color-info-light, #93c5fd)' }} />
<span className="text-gray-700 font-semibold">{enterpriseState.selectedOutletName}</span>
</div>
</div>
<Button
onClick={handleReturnToNetwork}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Return to Network View
</Button>
</div>
{enterpriseState.networkMetrics && (
<div className="mt-3 pt-3 border-t grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm"
style={{ borderColor: 'var(--color-info-light, #93c5fd)' }}>
<div>
<span style={{ color: 'var(--color-info)' }}>Network Average Sales:</span>
<span className="ml-2 font-semibold">{enterpriseState.networkMetrics.averageSales.toLocaleString()}</span>
</div>
<div>
<span style={{ color: 'var(--color-info)' }}>Total Outlets:</span>
<span className="ml-2 font-semibold">{enterpriseState.networkMetrics.childCount}</span>
</div>
<div>
<span style={{ color: 'var(--color-info)' }}>Network Total:</span>
<span className="ml-2 font-semibold">{enterpriseState.networkMetrics.totalSales.toLocaleString()}</span>
</div>
</div>
)}
</div>
)}
{/* Enhanced Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
{/* Title Section with Gradient Icon */}
<div className="flex items-center gap-4">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center shadow-lg"
style={{
background: 'linear-gradient(135deg, var(--color-info) 0%, var(--color-primary) 100%)',
}}
>
<Network className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold" style={{ color: 'var(--text-primary)' }}>
{t('enterprise.network_dashboard')}
</h1>
<p className="mt-1" style={{ color: 'var(--text-secondary)' }}>
{t('enterprise.network_summary_description')}
</p>
</div>
</div>
</div>
<p className="text-gray-600">
{t('enterprise.network_summary_description')}
</p>
</div>
{/* Network Summary Cards */}
@@ -234,6 +351,7 @@ const EnterpriseDashboardPage = () => {
data={childrenPerformance.rankings}
metric={selectedMetric}
period={selectedPeriod}
onOutletClick={handleOutletClick}
/>
) : (
<div className="h-96 flex items-center justify-center text-gray-500">
@@ -254,34 +372,78 @@ const EnterpriseDashboardPage = () => {
</CardHeader>
<CardContent>
{forecastSummary && forecastSummary.aggregated_forecasts ? (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-blue-600" />
<h3 className="font-semibold text-blue-800">{t('enterprise.total_demand')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Demand Card */}
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
style={{
backgroundColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-200)',
}}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-info-100)' }}
>
<Package className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-info-800)' }}>
{t('enterprise.total_demand')}
</h3>
</div>
<p className="text-2xl font-bold text-blue-900">
<p className="text-3xl font-bold" style={{ color: 'var(--color-info-900)' }}>
{Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
dayTotal + (product.predicted_demand || 0), 0), 0
).toLocaleString()}
</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Calendar className="w-4 h-4 text-green-600" />
<h3 className="font-semibold text-green-800">{t('enterprise.days_forecast')}</h3>
{/* Days Forecast Card */}
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-success-100)' }}
>
<Calendar className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-success-800)' }}>
{t('enterprise.days_forecast')}
</h3>
</div>
<p className="text-2xl font-bold text-green-900">
<p className="text-3xl font-bold" style={{ color: 'var(--color-success-900)' }}>
{forecastSummary.days_forecast || 7}
</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4 text-purple-600" />
<h3 className="font-semibold text-purple-800">{t('enterprise.avg_daily_demand')}</h3>
{/* Average Daily Demand Card */}
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
style={{
backgroundColor: 'var(--color-secondary-50)',
borderColor: 'var(--color-secondary-200)',
}}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-secondary-100)' }}
>
<Activity className="w-5 h-5" style={{ color: 'var(--color-secondary-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-secondary-800)' }}>
{t('enterprise.avg_daily_demand')}
</h3>
</div>
<p className="text-2xl font-bold text-purple-900">
<p className="text-3xl font-bold" style={{ color: 'var(--color-secondary-900)' }}>
{forecastSummary.aggregated_forecasts
? Math.round(Object.values(forecastSummary.aggregated_forecasts).reduce((total: number, day: any) =>
total + Object.values(day).reduce((dayTotal: number, product: any) =>
@@ -291,12 +453,27 @@ const EnterpriseDashboardPage = () => {
: 0}
</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-yellow-600" />
<h3 className="font-semibold text-yellow-800">{t('enterprise.last_updated')}</h3>
{/* Last Updated Card */}
<div
className="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-lg"
style={{
backgroundColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
}}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shadow-md"
style={{ backgroundColor: 'var(--color-warning-100)' }}
>
<Clock className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
</div>
<h3 className="font-semibold text-sm" style={{ color: 'var(--color-warning-800)' }}>
{t('enterprise.last_updated')}
</h3>
</div>
<p className="text-sm text-yellow-900">
<p className="text-lg font-semibold" style={{ color: 'var(--color-warning-900)' }}>
{forecastSummary.last_updated ?
new Date(forecastSummary.last_updated).toLocaleTimeString() :
'N/A'}
@@ -313,7 +490,7 @@ const EnterpriseDashboardPage = () => {
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">

View File

@@ -6,14 +6,14 @@ import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { useAIInsights, useAIInsightStats, useApplyInsight, useDismissInsight } from '../../../../api/hooks/aiInsights';
import { AIInsight } from '../../../../api/services/aiInsights';
import { useReasoningTranslation } from '../../../../hooks/useReasoningTranslation';
import { useTranslation } from 'react-i18next';
const AIInsightsPage: React.FC = () => {
const [selectedCategory, setSelectedCategory] = useState('all');
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id;
const { t } = useReasoningTranslation();
const { t } = useTranslation('reasoning');
// Fetch real insights from API
const { data: insightsData, isLoading, refetch } = useAIInsights(

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Brain, TrendingUp, AlertCircle, Play, RotateCcw, Eye, Loader, CheckCircle } from 'lucide-react';
import { Button, Badge, Modal, Table, Select, StatsGrid, StatusCard, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
@@ -40,7 +41,7 @@ interface ModelStatus {
}
const ModelsConfigPage: React.FC = () => {
const navigate = useNavigate();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
@@ -495,9 +496,9 @@ const ModelsConfigPage: React.FC = () => {
model={selectedModel}
onRetrain={handleRetrain}
onViewPredictions={(modelId) => {
// TODO: Navigate to forecast history or predictions view
// This should show historical predictions vs actual sales
console.log('View predictions for model:', modelId);
// Navigate to forecast history page filtered by this model
navigate(`/app/operations/forecasting?model_id=${modelId}&view=history`);
setShowModelDetailsModal(false);
}}
/>
)}

View File

@@ -0,0 +1,300 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Truck,
Plus,
Package,
MapPin,
Calendar,
ArrowRight,
Search,
Filter,
MoreVertical,
Clock,
CheckCircle,
AlertTriangle
} from 'lucide-react';
import {
Button,
StatsGrid,
Card,
CardContent,
CardHeader,
CardTitle,
Badge,
Input
} from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useTenant } from '../../../../stores/tenant.store';
import { useDistributionOverview } from '../../../../api/hooks/useEnterpriseDashboard';
import DistributionMap from '../../../../components/maps/DistributionMap';
const DistributionPage: React.FC = () => {
const { t } = useTranslation(['operations', 'common', 'dashboard']);
const { currentTenant: tenant } = useTenant();
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [activeTab, setActiveTab] = useState<'overview' | 'routes' | 'shipments'>('overview');
// Fetch real distribution data
const { data: distributionData, isLoading } = useDistributionOverview(
tenant?.id || '',
selectedDate,
{ enabled: !!tenant?.id }
);
// Derive stats from real data
const stats = [
{
title: t('operations:stats.active_routes', 'Rutas Activas'),
value: distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length || 0,
variant: 'info' as const,
icon: Truck,
},
{
title: t('operations:stats.pending_deliveries', 'Entregas Pendientes'),
value: distributionData?.status_counts?.pending || 0,
variant: 'warning' as const,
icon: Package,
},
{
title: t('operations:stats.completed_deliveries', 'Entregas Completadas'),
value: distributionData?.status_counts?.delivered || 0,
variant: 'success' as const,
icon: CheckCircle,
},
{
title: t('operations:stats.total_routes', 'Total Rutas'),
value: distributionData?.route_sequences?.length || 0,
variant: 'default' as const,
icon: MapPin,
},
];
const handleNewRoute = () => {
// Navigate to create route page or open modal
console.log('New route clicked');
};
if (!tenant) return null;
// Prepare shipment status data safely
const shipmentStatus = {
pending: distributionData?.status_counts?.pending || 0,
in_transit: distributionData?.status_counts?.in_transit || 0,
delivered: distributionData?.status_counts?.delivered || 0,
failed: distributionData?.status_counts?.failed || 0,
};
return (
<div className="space-y-6">
<PageHeader
title={t('operations:distribution.title', 'Distribución y Logística')}
description={t('operations:distribution.description', 'Gestión integral de la flota de reparto y seguimiento de entregas en tiempo real')}
actions={[
{
id: "date-select",
label: selectedDate,
variant: "outline" as const,
icon: Calendar,
onClick: () => { }, // In a real app this would trigger a date picker
size: "md"
},
{
id: "add-new-route",
label: t('operations:actions.new_route', 'Nueva Ruta'),
variant: "primary" as const,
icon: Plus,
onClick: handleNewRoute,
tooltip: t('operations:tooltips.new_route', 'Crear una nueva ruta de distribución'),
size: "md"
}
]}
/>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={4}
/>
{/* Main Content Areas */}
<div className="flex flex-col gap-6">
{/* Tabs Navigation */}
<div className="flex border-b border-gray-200">
<button
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'overview'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab('overview')}
>
Vista General
</button>
<button
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'routes'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab('routes')}
>
Listado de Rutas
</button>
<button
className={`px-4 py-2 font-medium text-sm transition-colors border-b-2 ${activeTab === 'shipments'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab('shipments')}
>
Listado de Envíos
</button>
</div>
{/* Content based on Active Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Map Section */}
<Card className="overflow-hidden border-none shadow-lg">
<CardHeader className="bg-white border-b sticky top-0 z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-blue-100 rounded-lg">
<MapPin className="w-5 h-5 text-blue-600" />
</div>
<div>
<CardTitle>{t('operations:map.title', 'Mapa de Distribución')}</CardTitle>
<p className="text-sm text-gray-500">Visualización en tiempo real de la flota</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
En Vivo
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="p-4 bg-slate-50">
<DistributionMap
routes={distributionData?.route_sequences || []}
shipments={shipmentStatus}
/>
</div>
</CardContent>
</Card>
{/* Recent Activity / Quick List */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Rutas en Progreso</CardTitle>
</CardHeader>
<CardContent>
{distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length > 0 ? (
<div className="space-y-4">
{distributionData.route_sequences
.filter((r: any) => r.status === 'in_progress')
.map((route: any) => (
<div key={route.id} className="flex items-center justify-between p-3 bg-white border rounded-lg shadow-sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-full">
<Truck className="w-4 h-4 text-blue-600" />
</div>
<div>
<p className="font-medium text-sm text-gray-900">Ruta {route.route_number}</p>
<p className="text-xs text-gray-500">{route.formatted_driver_name || 'Sin conductor asignado'}</p>
</div>
</div>
<Badge variant="info">En Ruta</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No hay rutas en progreso actualmente.
</div>
)}
</CardContent>
</Card>
</div>
</div>
)}
{activeTab === 'routes' && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Listado de Rutas</CardTitle>
<div className="flex gap-2">
<Input
placeholder="Buscar rutas..."
leftIcon={<Search className="w-4 h-4 text-gray-400" />}
className="w-64"
/>
<Button variant="outline" size="sm" leftIcon={<Filter className="w-4 h-4" />}>Filtros</Button>
</div>
</div>
</CardHeader>
<CardContent>
{(distributionData?.route_sequences?.length || 0) > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th className="px-4 py-3">Ruta</th>
<th className="px-4 py-3">Estado</th>
<th className="px-4 py-3">Distancia</th>
<th className="px-4 py-3">Duración Est.</th>
<th className="px-4 py-3">Paradas</th>
<th className="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
{distributionData.route_sequences.map((route: any) => (
<tr key={route.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{route.route_number}</td>
<td className="px-4 py-3">
<Badge variant={
route.status === 'completed' ? 'success' :
route.status === 'in_progress' ? 'info' :
route.status === 'pending' ? 'warning' : 'default'
}>
{route.status}
</Badge>
</td>
<td className="px-4 py-3">{route.total_distance_km?.toFixed(1) || '-'} km</td>
<td className="px-4 py-3">{route.estimated_duration_minutes || '-'} min</td>
<td className="px-4 py-3">{route.route_points?.length || 0}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" leftIcon={<MoreVertical className="w-4 h-4" />} />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
<p className="text-gray-500">No se encontraron rutas para esta fecha.</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Similar structure for Shipments tab, simplified for now */}
{activeTab === 'shipments' && (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed">
<Package className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<h3 className="text-lg font-medium text-gray-900">Gestión de Envíos</h3>
<p className="text-gray-500">Funcionalidad de listado detallado de envíos próximamente.</p>
</div>
)}
</div>
</div>
);
};
export default DistributionPage;

View File

@@ -271,9 +271,34 @@ const TeamPage: React.FC = () => {
};
const handleSaveMember = async () => {
// TODO: Implement member update logic
console.log('Saving member:', memberFormData);
setShowMemberModal(false);
try {
// Update user profile
if (selectedMember?.user_id) {
await userService.updateUser(selectedMember.user_id, {
full_name: memberFormData.full_name,
email: memberFormData.email,
phone: memberFormData.phone,
language: memberFormData.language,
timezone: memberFormData.timezone
});
}
// Update role if changed
if (memberFormData.role !== selectedMember?.role) {
await updateRoleMutation.mutateAsync({
tenantId,
memberUserId: selectedMember.user_id,
newRole: memberFormData.role
});
}
showToast.success(t('settings:team.member_updated_success', 'Miembro actualizado exitosamente'));
setShowMemberModal(false);
setModalMode('view');
} catch (error) {
console.error('Error updating member:', error);
showToast.error(t('settings:team.member_updated_error', 'Error al actualizar miembro'));
}
};
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {

View File

@@ -4,105 +4,12 @@ import { useTranslation } from 'react-i18next';
import { PublicLayout } from '../../components/layout';
import { Calendar, Clock, ArrowRight, Brain } from 'lucide-react';
interface BlogPost {
id: string;
slug: string;
titleKey: string;
excerptKey: string;
authorKey: string;
date: string;
readTime: string;
categoryKey: string;
tagsKeys: string[];
}
import { blogPosts } from '../../constants/blog';
const BlogPage: React.FC = () => {
const { t, i18n } = useTranslation(['blog', 'common']);
// Blog posts metadata - translations come from i18n
const blogPosts: BlogPost[] = [
{
id: '1',
slug: 'reducir-desperdicio-alimentario-panaderia',
titleKey: 'posts.waste_reduction.title',
excerptKey: 'posts.waste_reduction.excerpt',
authorKey: 'posts.waste_reduction.author',
date: '2025-01-15',
readTime: '8',
categoryKey: 'categories.management',
tagsKeys: [
'posts.waste_reduction.tags.food_waste',
'posts.waste_reduction.tags.sustainability',
'posts.waste_reduction.tags.ai',
'posts.waste_reduction.tags.management',
],
},
{
id: '2',
slug: 'ia-predecir-demanda-panaderia',
titleKey: 'posts.ai_prediction.title',
excerptKey: 'posts.ai_prediction.excerpt',
authorKey: 'posts.ai_prediction.author',
date: '2025-01-10',
readTime: '10',
categoryKey: 'categories.technology',
tagsKeys: [
'posts.ai_prediction.tags.ai',
'posts.ai_prediction.tags.machine_learning',
'posts.ai_prediction.tags.prediction',
'posts.ai_prediction.tags.technology',
],
},
{
id: '3',
slug: 'optimizar-produccion-panaderia-artesanal',
titleKey: 'posts.production_optimization.title',
excerptKey: 'posts.production_optimization.excerpt',
authorKey: 'posts.production_optimization.author',
date: '2025-01-05',
readTime: '12',
categoryKey: 'categories.production',
tagsKeys: [
'posts.production_optimization.tags.optimization',
'posts.production_optimization.tags.production',
'posts.production_optimization.tags.artisan',
'posts.production_optimization.tags.management',
],
},
{
id: '4',
slug: 'obrador-central-vs-produccion-local',
titleKey: 'posts.central_vs_local.title',
excerptKey: 'posts.central_vs_local.excerpt',
authorKey: 'posts.central_vs_local.author',
date: '2025-01-20',
readTime: '15',
categoryKey: 'categories.strategy',
tagsKeys: [
'posts.central_vs_local.tags.business_models',
'posts.central_vs_local.tags.central_bakery',
'posts.central_vs_local.tags.local_production',
'posts.central_vs_local.tags.scalability',
],
},
{
id: '5',
slug: 'gdpr-proteccion-datos-panaderia',
titleKey: 'posts.gdpr.title',
excerptKey: 'posts.gdpr.excerpt',
authorKey: 'posts.gdpr.author',
date: '2025-01-01',
readTime: '9',
categoryKey: 'categories.legal',
tagsKeys: [
'posts.gdpr.tags.gdpr',
'posts.gdpr.tags.rgpd',
'posts.gdpr.tags.privacy',
'posts.gdpr.tags.legal',
'posts.gdpr.tags.security',
],
},
];
// Blog posts are now imported from constants/blog.ts
return (
<PublicLayout

View File

@@ -0,0 +1,185 @@
import React from 'react';
import { useParams, Navigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PublicLayout } from '../../components/layout';
import { Calendar, Clock, ArrowLeft, User, Tag } from 'lucide-react';
import { blogPosts } from '../../constants/blog';
const BlogPostPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation(['blog', 'common']);
const post = blogPosts.find((p) => p.slug === slug);
if (!post) {
return <Navigate to="/blog" replace />;
}
// Helper to render content sections dynamically
const renderContent = () => {
// We need to access the structure of the content from the translation file
// Since i18next t() function returns a string, we need to know the structure beforehand
// or use returnObjects: true, but that returns an unknown type.
// For this implementation, we'll assume a standard structure based on the existing blog.json
// However, since the structure varies per post (e.g. problem_title, solution_1_title),
// we might need a more flexible approach or standardized content structure.
// Given the current JSON structure, it's quite specific per post.
// A robust way is to use `t` with `returnObjects: true` and iterate, but for now,
// let's try to render specific known sections if they exist, or just use a generic "content" key if we refactor.
// Actually, looking at blog.json, the content is nested under `content`.
// We can try to render the `intro` and then specific sections if we can infer them.
// But since the keys are like `problem_title`, `solution_1_title`, it's hard to iterate without knowing keys.
// A better approach for this specific codebase without refactoring all JSONs might be
// to just render the `intro` and `conclusion` and maybe a "read full guide" if it was a real app,
// but here we want to show the content.
// Let's use `t` to get the whole content object and iterate over keys?
// i18next `t` with `returnObjects: true` returns the object.
const content = t(`blog:${post.titleKey.replace('.title', '.content')}`, { returnObjects: true });
if (typeof content !== 'object' || content === null) {
return <p>{t('blog:post.content_not_available')}</p>;
}
return (
<div className="prose prose-lg max-w-none text-[var(--text-secondary)]">
{Object.entries(content).map(([key, value]) => {
if (key === 'intro' || key === 'conclusion') {
return <p key={key} className="mb-6">{value as string}</p>;
}
if (key.endsWith('_title')) {
return <h3 key={key} className="text-2xl font-bold text-[var(--text-primary)] mt-8 mb-4">{value as string}</h3>;
}
if (key.endsWith('_desc')) {
// Check if it contains markdown-like bold
const text = value as string;
const parts = text.split(/(\*\*.*?\*\*)/g);
return (
<p key={key} className="mb-4">
{parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
}
return part;
})}
</p>
);
}
if (Array.isArray(value)) {
return (
<ul key={key} className="list-disc pl-6 mb-6 space-y-2">
{(value as string[]).map((item, index) => {
// Handle bold text in list items
const parts = item.split(/(\*\*.*?\*\*)/g);
return (
<li key={index}>
{parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="text-[var(--text-primary)]">{part.slice(2, -2)}</strong>;
}
return part;
})}
</li>
);
})}
</ul>
);
}
// Fallback for other string keys that might be paragraphs
if (typeof value === 'string' && !key.includes('_title') && !key.includes('_desc')) {
return <p key={key} className="mb-4">{value}</p>;
}
return null;
})}
</div>
);
};
return (
<PublicLayout
variant="default"
contentPadding="md"
headerProps={{
showThemeToggle: true,
showAuthButtons: true,
showLanguageSelector: true,
variant: "default"
}}
>
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
{/* Back Link */}
<Link
to="/blog"
className="inline-flex items-center gap-2 text-[var(--text-tertiary)] hover:text-[var(--color-primary)] mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>{t('common:actions.back')}</span>
</Link>
{/* Header */}
<header className="mb-12">
<div className="flex flex-wrap items-center gap-4 mb-6">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{t(`blog:${post.categoryKey}`)}
</span>
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
<Calendar className="w-4 h-4" />
<span>
{new Date(post.date).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className="flex items-center gap-2 text-[var(--text-tertiary)]">
<Clock className="w-4 h-4" />
<span>{t('blog:post.read_time', { time: post.readTime })}</span>
</div>
</div>
<h1 className="text-4xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6 leading-tight">
{t(`blog:${post.titleKey}`)}
</h1>
<div className="flex items-center gap-3 pb-8 border-b border-[var(--border-primary)]">
<div className="w-12 h-12 rounded-full bg-[var(--bg-tertiary)] flex items-center justify-center text-[var(--text-secondary)]">
<User className="w-6 h-6" />
</div>
<div>
<div className="font-medium text-[var(--text-primary)]">
{t(`blog:${post.authorKey}`)}
</div>
<div className="text-sm text-[var(--text-tertiary)]">
{t('blog:post.author_role', { defaultValue: 'Contributor' })}
</div>
</div>
</div>
</header>
{/* Content */}
{renderContent()}
{/* Footer Tags */}
<div className="mt-12 pt-8 border-t border-[var(--border-primary)]">
<div className="flex flex-wrap gap-2">
{post.tagsKeys.map((tagKey) => (
<span
key={tagKey}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-[var(--bg-tertiary)] text-[var(--text-secondary)] text-sm"
>
<Tag className="w-3 h-3" />
{t(`blog:${tagKey}`)}
</span>
))}
</div>
</div>
</article>
</PublicLayout>
);
};
export default BlogPostPage;

View File

@@ -17,7 +17,7 @@ import {
Building,
Package,
BarChart3,
ForkKnife,
ChefHat,
CreditCard,
Bell,
@@ -295,10 +295,8 @@ const DemoPage = () => {
// Full success - navigate immediately
clearInterval(progressInterval);
setTimeout(() => {
const targetUrl = tier === 'enterprise'
? `/app/tenants/${sessionData.virtual_tenant_id}/enterprise`
: `/app/tenants/${sessionData.virtual_tenant_id}/dashboard`;
navigate(targetUrl);
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
navigate('/app/dashboard');
}, 1000);
return;
} else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') {
@@ -582,9 +580,8 @@ const DemoPage = () => {
{demoOptions.map((option) => (
<Card
key={option.id}
className={`cursor-pointer hover:shadow-xl transition-all border-2 ${
selectedTier === option.id ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
}`}
className={`cursor-pointer hover:shadow-xl transition-all border-2 ${selectedTier === option.id ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-700'
}`}
onClick={() => setSelectedTier(option.id)}
>
<CardHeader>
@@ -679,62 +676,69 @@ const DemoPage = () => {
))}
</div>
{/* Loading Progress */}
{/* Loading Progress Modal */}
{creatingTier !== null && (
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-center text-[var(--text-primary)]">Configurando Tu Demo</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span>Progreso total</span>
<span>{cloneProgress.overall}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${cloneProgress.overall}%` }}
></div>
</div>
{creatingTier === 'enterprise' && (
<div className="space-y-3 mt-4">
<div className="flex justify-between text-sm">
<span className="font-medium">Obrador Central</span>
<span>{cloneProgress.parent}%</span>
</div>
<div className="grid grid-cols-3 gap-3">
{cloneProgress.children.map((progress, index) => (
<div key={index} className="text-center">
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
></div>
</div>
<div className="text-xs mt-1">{progress}%</div>
</div>
))}
</div>
<div className="flex justify-between text-sm mt-2">
<span className="font-medium">Distribución</span>
<span>{cloneProgress.distribution}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${cloneProgress.distribution}%` }}
></div>
</div>
</div>
)}
<Modal
isOpen={creatingTier !== null}
onClose={() => { }}
size="md"
>
<ModalHeader
title="Configurando Tu Demo"
showCloseButton={false}
/>
<ModalBody padding="lg">
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span>Progreso total</span>
<span>{cloneProgress.overall}%</span>
</div>
</CardContent>
</Card>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${cloneProgress.overall}%` }}
></div>
</div>
<div className="text-center text-sm text-[var(--text-secondary)] mt-4">
{getLoadingMessage(creatingTier, cloneProgress.overall)}
</div>
{creatingTier === 'enterprise' && (
<div className="space-y-3 mt-4">
<div className="flex justify-between text-sm">
<span className="font-medium">Obrador Central</span>
<span>{cloneProgress.parent}%</span>
</div>
<div className="grid grid-cols-3 gap-3">
{cloneProgress.children.map((progress, index) => (
<div key={index} className="text-center">
<div className="text-xs text-[var(--text-tertiary)] mb-1">Outlet {index + 1}</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
></div>
</div>
<div className="text-xs mt-1">{progress}%</div>
</div>
))}
</div>
<div className="flex justify-between text-sm mt-2">
<span className="font-medium">Distribución</span>
<span>{cloneProgress.distribution}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-purple-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${cloneProgress.distribution}%` }}
></div>
</div>
</div>
)}
</div>
</ModalBody>
</Modal>
)}
{/* Error Alert */}
@@ -798,11 +802,9 @@ const DemoPage = () => {
<Button
variant="primary"
onClick={() => {
const tierUrl = partialWarning.tier === 'enterprise'
? `/demo/${partialWarning.sessionData.session_id}/enterprise`
: `/demo/${partialWarning.sessionData.session_id}`;
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
setPartialWarning(null);
navigate(tierUrl);
navigate('/app/dashboard');
}}
>
Continuar con Demo Parcial
@@ -881,11 +883,9 @@ const DemoPage = () => {
<Button
variant="secondary"
onClick={() => {
const tierUrl = timeoutModal.tier === 'enterprise'
? `/demo/${timeoutModal.sessionData.session_id}/enterprise`
: `/demo/${timeoutModal.sessionData.session_id}`;
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
setTimeoutModal(null);
navigate(tierUrl);
navigate('/app/dashboard');
}}
>
Iniciar con Datos Parciales
@@ -905,42 +905,6 @@ const DemoPage = () => {
</Modal>
)}
{/* Comparison Section */}
<div className="mt-16">
<h2 className="text-3xl font-bold text-center mb-8 text-[var(--text-primary)]">Comparación de Funcionalidades</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<div className="grid grid-cols-3 divide-x divide-[var(--border-primary)]">
<div className="p-4 bg-gray-50 dark:bg-gray-700">
<h3 className="font-semibold text-center text-[var(--text-primary)]">Función</h3>
</div>
<div className="p-4">
<div className="text-center font-semibold text-blue-600 dark:text-blue-400">Professional</div>
<div className="text-center text-sm text-[var(--text-tertiary)]">Individual Bakery</div>
</div>
<div className="p-4">
<div className="text-center font-semibold text-purple-600 dark:text-purple-400">Enterprise</div>
<div className="text-center text-sm text-[var(--text-tertiary)]">Chain of Bakeries</div>
</div>
</div>
{[
{ feature: 'Número Máximo de Ubicaciones', professional: '1', enterprise: 'Ilimitado' },
{ feature: 'Gestión de Inventario', professional: '✓', enterprise: '✓ Agregado' },
{ feature: 'Forecasting con IA', professional: 'Personalizado', enterprise: 'Agregado + Individual' },
{ feature: 'Planificación de Producción', professional: '✓', enterprise: '✓ Centralizada' },
{ feature: 'Transferencias Internas', professional: '×', enterprise: '✓ Optimizadas' },
{ feature: 'Logística y Rutas', professional: '×', enterprise: '✓ Optimización VRP' },
{ feature: 'Dashboard Multi-ubicación', professional: '×', enterprise: '✓ Visión de Red' },
{ feature: 'Reportes Consolidados', professional: '×', enterprise: '✓ Nivel de Red' }
].map((row, index) => (
<div key={index} className={`grid grid-cols-3 divide-x divide-[var(--border-primary)] ${index % 2 === 0 ? 'bg-gray-50 dark:bg-gray-700' : 'bg-white dark:bg-gray-800'}`}>
<div className="p-3 text-sm text-[var(--text-secondary)]">{row.feature}</div>
<div className="p-3 text-center text-sm text-[var(--text-secondary)]">{row.professional}</div>
<div className="p-3 text-center text-sm text-[var(--text-secondary)]">{row.enterprise}</div>
</div>
))}
</div>
</div>
</div>
</section>
</PublicLayout>

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { ROUTES } from '../../router/routes.config';
const UnauthorizedPage: React.FC = () => (
<div className="flex items-center justify-center min-h-screen bg-bg-primary">
<div className="text-center max-w-md mx-auto px-6">
<div className="mb-6">
<div className="w-16 h-16 mx-auto mb-4 bg-color-error rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-text-inverse"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.732 19.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">
Acceso no autorizado
</h1>
<p className="text-text-secondary mb-6">
No tienes permisos para acceder a esta página. Contacta con tu administrador si crees que esto es un error.
</p>
</div>
<div className="flex flex-col gap-3">
<button
onClick={() => window.history.back()}
className="btn btn-primary"
>
Volver atrás
</button>
<button
onClick={() => window.location.href = ROUTES.DASHBOARD}
className="btn btn-outline"
>
Ir al Panel de Control
</button>
</div>
</div>
</div>
);
export default UnauthorizedPage;

View File

@@ -15,12 +15,14 @@ const TermsOfServicePage = React.lazy(() => import('../pages/public/TermsOfServi
const CookiePolicyPage = React.lazy(() => import('../pages/public/CookiePolicyPage'));
const CookiePreferencesPage = React.lazy(() => import('../pages/public/CookiePreferencesPage'));
const BlogPage = React.lazy(() => import('../pages/public/BlogPage'));
const BlogPostPage = React.lazy(() => import('../pages/public/BlogPostPage'));
const AboutPage = React.lazy(() => import('../pages/public/AboutPage'));
const CareersPage = React.lazy(() => import('../pages/public/CareersPage'));
const HelpCenterPage = React.lazy(() => import('../pages/public/HelpCenterPage'));
const DocumentationPage = React.lazy(() => import('../pages/public/DocumentationPage'));
const ContactPage = React.lazy(() => import('../pages/public/ContactPage'));
const FeedbackPage = React.lazy(() => import('../pages/public/FeedbackPage'));
const UnauthorizedPage = React.lazy(() => import('../pages/public/UnauthorizedPage'));
const DashboardPage = React.lazy(() => import('../pages/app/DashboardPage'));
// Operations pages
@@ -32,6 +34,7 @@ const SuppliersPage = React.lazy(() => import('../pages/app/operations/suppliers
const OrdersPage = React.lazy(() => import('../pages/app/operations/orders/OrdersPage'));
const POSPage = React.lazy(() => import('../pages/app/operations/pos/POSPage'));
const MaquinariaPage = React.lazy(() => import('../pages/app/operations/maquinaria/MaquinariaPage'));
const DistributionPage = React.lazy(() => import('../pages/app/operations/distribution/DistributionPage'));
// Analytics pages
const ProductionAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProductionAnalyticsPage'));
@@ -75,6 +78,7 @@ export const AppRouter: React.FC = () => {
{/* Company Routes - Public */}
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/careers" element={<CareersPage />} />
@@ -89,19 +93,21 @@ export const AppRouter: React.FC = () => {
<Route path="/terms" element={<TermsOfServicePage />} />
<Route path="/cookies" element={<CookiePolicyPage />} />
<Route path="/cookie-preferences" element={<CookiePreferencesPage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="/401" element={<UnauthorizedPage />} />
{/* Protected Routes with AppShell Layout */}
<Route
path="/app"
<Route
path="/app"
element={
<ProtectedRoute>
<AppShell>
<DashboardPage />
</AppShell>
</ProtectedRoute>
}
}
/>
<Route
<Route
path="/app/dashboard"
element={
<ProtectedRoute>
@@ -143,6 +149,16 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/operations/distribution"
element={
<ProtectedRoute>
<AppShell>
<DistributionPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Database Routes - Current Bakery Status */}
<Route
@@ -291,15 +307,15 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/analytics/sales"
<Route
path="/app/analytics/sales"
element={
<ProtectedRoute>
<AppShell>
<SalesAnalyticsPage />
</AppShell>
</ProtectedRoute>
}
}
/>
<Route
path="/app/analytics/scenario-simulation"

View File

@@ -42,7 +42,7 @@ export const ROUTES = {
FORGOT_PASSWORD: '/forgot-password',
RESET_PASSWORD: '/reset-password',
VERIFY_EMAIL: '/verify-email',
// Dashboard
DASHBOARD: '/app/dashboard',
@@ -63,21 +63,21 @@ export const ROUTES = {
PRODUCTION_QUALITY: '/production/quality',
PRODUCTION_REPORTS: '/production/reports',
PRODUCTION_ANALYTICS: '/app/analytics/production',
// Sales & Analytics
SALES: '/sales',
SALES_DATA: '/sales/data',
SALES_ANALYTICS: '/sales/analytics',
SALES_REPORTS: '/sales/reports',
SALES_FORECASTING: '/sales/forecasting',
// Forecasting & ML
FORECASTING: '/forecasting',
FORECASTING_MODELS: '/forecasting/models',
FORECASTING_PREDICTIONS: '/forecasting/predictions',
FORECASTING_TRAINING: '/forecasting/training',
FORECASTING_ANALYTICS: '/forecasting/analytics',
// Orders Management
ORDERS: '/app/database/orders',
ORDERS_LIST: '/orders/list',
@@ -92,6 +92,9 @@ export const ROUTES = {
PROCUREMENT_DELIVERIES: '/procurement/deliveries',
PROCUREMENT_ANALYTICS: '/app/analytics/procurement',
// Distribution
DISTRIBUTION: '/app/operations/distribution',
// Recipes
RECIPES: '/app/database/recipes',
@@ -117,13 +120,13 @@ export const ROUTES = {
TRAINING_JOBS: '/training/jobs',
TRAINING_EVALUATION: '/training/evaluation',
TRAINING_DATASETS: '/training/datasets',
// Notifications
NOTIFICATIONS: '/notifications',
NOTIFICATIONS_LIST: '/notifications/list',
NOTIFICATIONS_TEMPLATES: '/notifications/templates',
NOTIFICATIONS_SETTINGS: '/notifications/settings',
// Settings
SETTINGS: '/settings',
SETTINGS_PROFILE: '/app/settings/profile',
@@ -149,7 +152,7 @@ export const ROUTES = {
TERMS_OF_SERVICE: '/terms',
COOKIE_POLICY: '/cookies',
COOKIE_PREFERENCES: '/cookie-preferences',
// Reports
REPORTS: '/reports',
REPORTS_PRODUCTION: '/reports/production',
@@ -157,7 +160,7 @@ export const ROUTES = {
REPORTS_SALES: '/reports/sales',
REPORTS_FINANCIAL: '/reports/financial',
REPORTS_QUALITY: '/reports/quality',
// Help & Support
HELP: '/help',
HELP_DOCUMENTATION: '/help/docs',
@@ -285,6 +288,17 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/operations/distribution',
name: 'Distribution',
component: 'DistributionPage',
title: 'Distribución',
icon: 'truck',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
requiredSubscriptionFeature: 'distribution',
},
],
},
@@ -395,17 +409,7 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/tenants/:tenantId/enterprise',
name: 'EnterpriseDashboard',
component: 'EnterpriseDashboardPage',
title: 'Enterprise Dashboard',
icon: 'analytics',
requiresAuth: true,
requiredSubscriptionFeature: 'multi_location_dashboard',
showInNavigation: true,
showInBreadcrumbs: true,
},
],
},
@@ -450,7 +454,7 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
{
path: '/app/database/recipes',
name: 'Recipes',
component: 'RecipesPage',
@@ -641,7 +645,7 @@ export const getRouteByPath = (path: string): RouteConfig | undefined => {
}
return undefined;
};
return findRoute(routesConfig, path);
};
@@ -660,7 +664,7 @@ export const getRouteByName = (name: string): RouteConfig | undefined => {
}
return undefined;
};
return findRoute(routesConfig, name);
};
@@ -673,14 +677,14 @@ export const getNavigationRoutes = (): RouteConfig[] => {
children: route.children ? filterNavRoutes(route.children) : undefined,
}));
};
return filterNavRoutes(routesConfig);
};
export const getBreadcrumbs = (path: string): RouteConfig[] => {
const breadcrumbs: RouteConfig[] = [];
const pathSegments = path.split('/').filter(segment => segment);
let currentPath = '';
for (const segment of pathSegments) {
currentPath += `/${segment}`;
@@ -689,7 +693,7 @@ export const getBreadcrumbs = (path: string): RouteConfig[] => {
breadcrumbs.push(route);
}
}
return breadcrumbs;
};
@@ -697,13 +701,13 @@ export const hasPermission = (route: RouteConfig, userPermissions: string[]): bo
if (!route.requiredPermissions || route.requiredPermissions.length === 0) {
return true;
}
// Check for wildcard permission
if (userPermissions.includes('*')) {
return true;
}
return route.requiredPermissions.every(permission =>
return route.requiredPermissions.every(permission =>
userPermissions.includes(permission)
);
};
@@ -712,32 +716,32 @@ export const hasRole = (route: RouteConfig, userRoles: string[]): boolean => {
if (!route.requiredRoles || route.requiredRoles.length === 0) {
return true;
}
return route.requiredRoles.some(role =>
return route.requiredRoles.some(role =>
userRoles.includes(role)
);
};
export const canAccessRoute = (
route: RouteConfig,
isAuthenticated: boolean,
userRoles: string[] = [],
route: RouteConfig,
isAuthenticated: boolean,
userRoles: string[] = [],
userPermissions: string[] = []
): boolean => {
// Check authentication requirement
if (route.requiresAuth && !isAuthenticated) {
return false;
}
// Check role requirements
if (!hasRole(route, userRoles)) {
return false;
}
// Check permission requirements
if (!hasPermission(route, userPermissions)) {
return false;
}
return true;
};

View File

@@ -161,12 +161,8 @@ export const useAuthStore = create<AuthState>()(
console.warn('Failed to clear tenant store on logout:', err);
});
// Clear notification storage to ensure notifications don't persist across sessions
import('../hooks/useNotifications').then(({ clearNotificationStorage }) => {
clearNotificationStorage();
}).catch(err => {
console.warn('Failed to clear notification storage on logout:', err);
});
// Note: Notification storage is now handled by React Query cache
// which is cleared automatically on logout
set({
user: null,

View File

@@ -979,4 +979,48 @@
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ============================================================================
ENTERPRISE DASHBOARD ANIMATIONS
============================================================================ */
/* Shimmer effect for top performers and highlights */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.animate-shimmer {
animation: shimmer 3s infinite linear;
}
/* Pulse glow effect for status indicators */
@keyframes pulse-glow {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Dashboard card hover effects */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-xl);
}

View File

@@ -1,219 +0,0 @@
/**
* Next-Generation Alert Types
*
* TypeScript definitions for enriched, context-aware alerts
* Matches shared/schemas/alert_types.py
*/
export enum AlertTypeClass {
ACTION_NEEDED = 'action_needed',
PREVENTED_ISSUE = 'prevented_issue',
TREND_WARNING = 'trend_warning',
ESCALATION = 'escalation',
INFORMATION = 'information'
}
export enum PriorityLevel {
CRITICAL = 'critical', // 90-100: Needs decision in next 2 hours
IMPORTANT = 'important', // 70-89: Needs decision today
STANDARD = 'standard', // 50-69: Review when convenient
INFO = 'info' // 0-49: For awareness
}
export enum PlacementHint {
TOAST = 'toast',
ACTION_QUEUE = 'action_queue',
DASHBOARD_INLINE = 'dashboard_inline',
NOTIFICATION_PANEL = 'notification_panel',
EMAIL_DIGEST = 'email_digest'
}
export enum SmartActionType {
APPROVE_PO = 'approve_po',
REJECT_PO = 'reject_po',
CALL_SUPPLIER = 'call_supplier',
NAVIGATE = 'navigate',
ADJUST_PRODUCTION = 'adjust_production',
NOTIFY_CUSTOMER = 'notify_customer',
CANCEL_AUTO_ACTION = 'cancel_auto_action',
OPEN_REASONING = 'open_reasoning',
SNOOZE = 'snooze',
DISMISS = 'dismiss',
MARK_READ = 'mark_read'
}
export interface SmartAction {
label: string;
type: SmartActionType;
variant: 'primary' | 'secondary' | 'tertiary' | 'danger';
metadata: Record<string, any>;
disabled?: boolean;
disabled_reason?: string;
estimated_time_minutes?: number;
consequence?: string;
}
export interface OrchestratorContext {
already_addressed: boolean;
action_type?: string;
action_id?: string;
action_status?: string;
delivery_date?: string;
reasoning?: Record<string, any>;
estimated_resolution_time?: string;
}
export interface BusinessImpact {
financial_impact_eur?: number;
affected_orders?: number;
affected_customers?: string[];
production_batches_at_risk?: string[];
stockout_risk_hours?: number;
waste_risk_kg?: number;
customer_satisfaction_impact?: 'high' | 'medium' | 'low';
}
export interface UrgencyContext {
deadline?: string;
time_until_consequence_hours?: number;
can_wait_until_tomorrow: boolean;
peak_hour_relevant: boolean;
auto_action_countdown_seconds?: number;
}
export interface UserAgency {
can_user_fix: boolean;
requires_external_party: boolean;
external_party_name?: string;
external_party_contact?: string;
blockers?: string[];
suggested_workaround?: string;
}
export interface TrendContext {
metric_name: string;
current_value: number;
baseline_value: number;
change_percentage: number;
direction: 'increasing' | 'decreasing';
significance: 'high' | 'medium' | 'low';
period_days: number;
possible_causes?: string[];
}
export interface EnrichedAlert {
// Original Alert Data
id: string;
tenant_id: string;
service: string;
alert_type: string;
title: string;
message: string;
// Classification
type_class: AlertTypeClass;
priority_level: PriorityLevel;
priority_score: number;
// Context Enrichment
orchestrator_context?: OrchestratorContext;
business_impact?: BusinessImpact;
urgency_context?: UrgencyContext;
user_agency?: UserAgency;
trend_context?: TrendContext;
// AI Reasoning
ai_reasoning_summary?: string;
reasoning_data?: Record<string, any>;
confidence_score?: number;
// Actions
actions: SmartAction[];
primary_action?: SmartAction;
// UI Placement
placement: PlacementHint[];
// Grouping
group_id?: string;
is_group_summary: boolean;
grouped_alert_count?: number;
grouped_alert_ids?: string[];
// Metadata
created_at: string;
enriched_at: string;
alert_metadata: Record<string, any>;
status: 'active' | 'resolved' | 'acknowledged' | 'snoozed';
}
export interface PriorityScoreComponents {
business_impact_score: number;
urgency_score: number;
user_agency_score: number;
confidence_score: number;
final_score: number;
weights: Record<string, number>;
}
// Helper functions
export function getPriorityColor(level: PriorityLevel): string {
switch (level) {
case PriorityLevel.CRITICAL:
return 'var(--color-error)';
case PriorityLevel.IMPORTANT:
return 'var(--color-warning)';
case PriorityLevel.STANDARD:
return 'var(--color-info)';
case PriorityLevel.INFO:
return 'var(--color-success)';
}
}
export function getPriorityIcon(level: PriorityLevel): string {
switch (level) {
case PriorityLevel.CRITICAL:
return 'alert-triangle';
case PriorityLevel.IMPORTANT:
return 'alert-circle';
case PriorityLevel.STANDARD:
return 'info';
case PriorityLevel.INFO:
return 'check-circle';
}
}
export function getTypeClassBadgeVariant(typeClass: AlertTypeClass): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline' {
switch (typeClass) {
case AlertTypeClass.ACTION_NEEDED:
return 'error';
case AlertTypeClass.PREVENTED_ISSUE:
return 'success';
case AlertTypeClass.TREND_WARNING:
return 'warning';
case AlertTypeClass.ESCALATION:
return 'error';
case AlertTypeClass.INFORMATION:
return 'info';
}
}
export function formatTimeUntilConsequence(hours?: number): string {
if (!hours) return '';
if (hours < 1) {
return `${Math.round(hours * 60)} minutes`;
} else if (hours < 24) {
return `${Math.round(hours)} hours`;
} else {
return `${Math.round(hours / 24)} days`;
}
}
export function shouldShowToast(alert: EnrichedAlert): boolean {
return alert.placement.includes(PlacementHint.TOAST);
}
export function shouldShowInActionQueue(alert: EnrichedAlert): boolean {
return alert.placement.includes(PlacementHint.ACTION_QUEUE);
}

View File

@@ -1,369 +0,0 @@
/**
* Event System Type Definitions
*
* Matches backend event architecture with three-tier model:
* - ALERT: Actionable events requiring user decision
* - NOTIFICATION: Informational state changes
* - RECOMMENDATION: AI-generated suggestions
*/
// ============================================================
// Event Classifications
// ============================================================
export type EventClass = 'alert' | 'notification' | 'recommendation';
export type EventDomain =
| 'inventory'
| 'production'
| 'supply_chain'
| 'demand'
| 'operations';
export type PriorityLevel = 'critical' | 'important' | 'standard' | 'info';
export type AlertTypeClass =
| 'action_needed'
| 'prevented_issue'
| 'trend_warning'
| 'escalation'
| 'information';
export type NotificationType =
| 'state_change'
| 'completion'
| 'arrival'
| 'departure'
| 'update'
| 'system_event';
export type RecommendationType =
| 'optimization'
| 'cost_reduction'
| 'risk_mitigation'
| 'trend_insight'
| 'best_practice';
// ============================================================
// Base Event Interface
// ============================================================
export interface BaseEvent {
id: string;
tenant_id: string;
event_class: EventClass;
event_domain: EventDomain;
event_type: string;
service: string;
title: string;
message: string;
timestamp: string;
created_at: string;
metadata?: Record<string, any>;
_channel?: string; // Added by gateway for frontend routing
}
// ============================================================
// Alert (Full Enrichment)
// ============================================================
export interface OrchestratorContext {
already_addressed?: boolean;
action_type?: string;
action_id?: string;
action_status?: string;
delivery_date?: string;
reasoning?: string;
}
export interface BusinessImpact {
financial_impact_eur?: number;
affected_orders?: number;
affected_customers?: string[];
production_batches_at_risk?: string[];
stockout_risk_hours?: number;
waste_risk_kg?: number;
customer_satisfaction_impact?: 'high' | 'medium' | 'low';
}
export interface UrgencyContext {
deadline?: string;
time_until_consequence_hours?: number;
can_wait_until_tomorrow?: boolean;
peak_hour_relevant?: boolean;
auto_action_countdown_seconds?: number;
}
export interface UserAgency {
can_user_fix?: boolean;
requires_external_party?: boolean;
external_party_name?: string;
external_party_contact?: string;
blockers?: string[];
suggested_workaround?: string;
}
export interface SmartAction {
type: string;
label: string;
variant?: 'primary' | 'secondary' | 'danger' | 'success';
metadata?: Record<string, any>;
estimated_time_minutes?: number;
consequence?: string;
disabled?: boolean;
disabled_reason?: string;
}
export interface Alert extends BaseEvent {
event_class: 'alert';
type_class: AlertTypeClass;
status: 'active' | 'acknowledged' | 'resolved' | 'dismissed' | 'in_progress';
// Priority
priority_score: number; // 0-100
priority_level: PriorityLevel;
// Enrichment context
orchestrator_context?: OrchestratorContext;
business_impact?: BusinessImpact;
urgency_context?: UrgencyContext;
user_agency?: UserAgency;
trend_context?: Record<string, any>;
// Smart actions
actions?: SmartAction[];
// AI reasoning
ai_reasoning_summary?: string;
confidence_score?: number;
// Timing
timing_decision?: 'send_now' | 'schedule_later' | 'batch_for_digest';
scheduled_send_time?: string;
placement?: string[];
// Escalation & chaining
action_created_at?: string;
superseded_by_action_id?: string;
hidden_from_ui?: boolean;
// Timestamps
updated_at?: string;
resolved_at?: string;
// Legacy fields (for backward compatibility)
alert_type?: string;
item_type?: 'alert';
}
// ============================================================
// Notification (Lightweight)
// ============================================================
export interface Notification extends BaseEvent {
event_class: 'notification';
notification_type: NotificationType;
// Entity context
entity_type?: string;
entity_id?: string;
old_state?: string;
new_state?: string;
// Display
placement?: string[];
// TTL
expires_at?: string;
// Legacy fields
item_type?: 'notification';
}
// ============================================================
// Recommendation (AI Suggestions)
// ============================================================
export interface Recommendation extends BaseEvent {
event_class: 'recommendation';
recommendation_type: RecommendationType;
// Light priority
priority_level: PriorityLevel;
// Context
estimated_impact?: {
financial_savings_eur?: number;
time_saved_hours?: number;
efficiency_gain_percent?: number;
[key: string]: any;
};
suggested_actions?: SmartAction[];
// AI reasoning
ai_reasoning_summary?: string;
confidence_score?: number;
// Dismissal
dismissed_at?: string;
dismissed_by?: string;
// Timestamps
updated_at?: string;
// Legacy fields
item_type?: 'recommendation';
}
// ============================================================
// Union Types
// ============================================================
export type Event = Alert | Notification | Recommendation;
// Type guards
export function isAlert(event: Event): event is Alert {
return event.event_class === 'alert' || event.item_type === 'alert';
}
export function isNotification(event: Event): event is Notification {
return event.event_class === 'notification';
}
export function isRecommendation(event: Event): event is Recommendation {
return event.event_class === 'recommendation' || event.item_type === 'recommendation';
}
// ============================================================
// Channel Patterns
// ============================================================
export type ChannelPattern =
| `${EventDomain}.${Exclude<EventClass, 'recommendation'>}` // e.g., "inventory.alerts"
| `${EventDomain}.*` // e.g., "inventory.*"
| `*.${Exclude<EventClass, 'recommendation'>}` // e.g., "*.alerts"
| 'recommendations'
| '*.*';
// ============================================================
// Hook Configuration Types
// ============================================================
export interface UseAlertsConfig {
domains?: EventDomain[];
minPriority?: PriorityLevel;
typeClass?: AlertTypeClass[];
includeResolved?: boolean;
maxAge?: number; // seconds
}
export interface UseNotificationsConfig {
domains?: EventDomain[];
eventTypes?: string[];
maxAge?: number; // seconds, default 3600 (1 hour)
}
export interface UseRecommendationsConfig {
domains?: EventDomain[];
includeDismissed?: boolean;
minConfidence?: number; // 0.0 - 1.0
}
// ============================================================
// SSE Event Types
// ============================================================
export interface SSEConnectionEvent {
type: 'connected';
message: string;
channels: string[];
timestamp: number;
}
export interface SSEHeartbeatEvent {
type: 'heartbeat';
timestamp: number;
}
export interface SSEInitialStateEvent {
events: Event[];
}
// ============================================================
// Backward Compatibility (Legacy Alert Format)
// ============================================================
/**
* @deprecated Use Alert type instead
*/
export interface LegacyAlert {
id: string;
tenant_id: string;
item_type: 'alert' | 'recommendation';
alert_type: string;
service: string;
title: string;
message: string;
priority_level?: string;
priority_score?: number;
type_class?: string;
status?: string;
actions?: any[];
metadata?: Record<string, any>;
timestamp: string;
created_at: string;
}
/**
* Convert legacy alert format to new Event format
*/
export function convertLegacyAlert(legacy: LegacyAlert): Event {
const eventClass: EventClass = legacy.item_type === 'recommendation' ? 'recommendation' : 'alert';
// Infer domain from service (best effort)
const domainMap: Record<string, EventDomain> = {
'inventory': 'inventory',
'production': 'production',
'procurement': 'supply_chain',
'forecasting': 'demand',
'orchestrator': 'operations',
};
const event_domain = domainMap[legacy.service] || 'operations';
const base = {
id: legacy.id,
tenant_id: legacy.tenant_id,
event_class: eventClass,
event_domain,
event_type: legacy.alert_type,
service: legacy.service,
title: legacy.title,
message: legacy.message,
timestamp: legacy.timestamp,
created_at: legacy.created_at,
metadata: legacy.metadata,
};
if (eventClass === 'alert') {
return {
...base,
event_class: 'alert',
type_class: (legacy.type_class as AlertTypeClass) || 'action_needed',
status: (legacy.status as any) || 'active',
priority_score: legacy.priority_score || 50,
priority_level: (legacy.priority_level as PriorityLevel) || 'standard',
actions: legacy.actions as SmartAction[],
alert_type: legacy.alert_type,
item_type: 'alert',
} as Alert;
} else {
return {
...base,
event_class: 'recommendation',
recommendation_type: 'trend_insight',
priority_level: (legacy.priority_level as PriorityLevel) || 'info',
suggested_actions: legacy.actions as SmartAction[],
item_type: 'recommendation',
} as Recommendation;
}
}

View File

@@ -1,638 +0,0 @@
/**
* Alert Helper Utilities
* Provides grouping, filtering, sorting, and categorization logic for alerts
*/
import { PriorityLevel } from '../types/alerts';
import type { Alert } from '../types/events';
import { TFunction } from 'i18next';
import { translateAlertTitle, translateAlertMessage } from './alertI18n';
export type AlertSeverity = 'urgent' | 'high' | 'medium' | 'low';
export type AlertCategory = 'inventory' | 'production' | 'orders' | 'equipment' | 'quality' | 'suppliers' | 'other';
export type TimeGroup = 'today' | 'yesterday' | 'this_week' | 'older';
/**
* Map Alert priority_score to AlertSeverity
*/
export function getSeverity(alert: Alert): AlertSeverity {
// Map based on priority_score for more granularity
if (alert.priority_score >= 80) return 'urgent';
if (alert.priority_score >= 60) return 'high';
if (alert.priority_score >= 40) return 'medium';
return 'low';
}
export interface AlertGroup {
id: string;
type: 'time' | 'category' | 'similarity';
key: string;
title: string;
count: number;
priority_level: PriorityLevel;
alerts: Alert[];
collapsed?: boolean;
}
export interface AlertFilters {
priorities: PriorityLevel[];
categories: AlertCategory[];
timeRange: TimeGroup | 'all';
search: string;
showSnoozed: boolean;
}
export interface SnoozedAlert {
alertId: string;
until: number; // timestamp
reason?: string;
}
/**
* Categorize alert based on title and message content
*/
export function categorizeAlert(alert: AlertOrNotification, t?: TFunction): AlertCategory {
let title = alert.title;
let message = alert.message;
// Use translated text if translation function is provided
if (t) {
title = translateAlertTitle(alert, t);
message = translateAlertMessage(alert, t);
}
const text = `${title} ${message}`.toLowerCase();
if (text.includes('stock') || text.includes('inventario') || text.includes('caducad') || text.includes('expi')) {
return 'inventory';
}
if (text.includes('producci') || text.includes('production') || text.includes('lote') || text.includes('batch')) {
return 'production';
}
if (text.includes('pedido') || text.includes('order') || text.includes('entrega') || text.includes('delivery')) {
return 'orders';
}
if (text.includes('equip') || text.includes('maquina') || text.includes('mantenimiento') || text.includes('maintenance')) {
return 'equipment';
}
if (text.includes('calidad') || text.includes('quality') || text.includes('temperatura') || text.includes('temperature')) {
return 'quality';
}
if (text.includes('proveedor') || text.includes('supplier') || text.includes('compra') || text.includes('purchase')) {
return 'suppliers';
}
return 'other';
}
/**
* Get category display name
*/
export function getCategoryName(category: AlertCategory, locale: string = 'es'): string {
const names: Record<AlertCategory, Record<string, string>> = {
inventory: { es: 'Inventario', en: 'Inventory' },
production: { es: 'Producción', en: 'Production' },
orders: { es: 'Pedidos', en: 'Orders' },
equipment: { es: 'Maquinaria', en: 'Equipment' },
quality: { es: 'Calidad', en: 'Quality' },
suppliers: { es: 'Proveedores', en: 'Suppliers' },
other: { es: 'Otros', en: 'Other' },
};
return names[category][locale] || names[category]['es'];
}
/**
* Get category icon emoji
*/
export function getCategoryIcon(category: AlertCategory): string {
const icons: Record<AlertCategory, string> = {
inventory: '📦',
production: '🏭',
orders: '🚚',
equipment: '⚙️',
quality: '✅',
suppliers: '🏢',
other: '📋',
};
return icons[category];
}
/**
* Determine time group for an alert
*/
export function getTimeGroup(timestamp: string): TimeGroup {
const alertDate = new Date(timestamp);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
if (alertDate >= today) {
return 'today';
}
if (alertDate >= yesterday) {
return 'yesterday';
}
if (alertDate >= weekAgo) {
return 'this_week';
}
return 'older';
}
/**
* Get time group display name
*/
export function getTimeGroupName(group: TimeGroup, locale: string = 'es'): string {
const names: Record<TimeGroup, Record<string, string>> = {
today: { es: 'Hoy', en: 'Today' },
yesterday: { es: 'Ayer', en: 'Yesterday' },
this_week: { es: 'Esta semana', en: 'This week' },
older: { es: 'Anteriores', en: 'Older' },
};
return names[group][locale] || names[group]['es'];
}
/**
* Check if two alerts are similar enough to group together
*/
export function areAlertsSimilar(alert1: Alert, alert2: Alert): boolean {
// Must be same category and severity
if (categorizeAlert(alert1) !== categorizeAlert(alert2)) {
return false;
}
if (getSeverity(alert1) !== getSeverity(alert2)) {
return false;
}
// Extract key terms from titles
const getKeyTerms = (title: string): Set<string> => {
const stopWords = new Set(['de', 'en', 'el', 'la', 'los', 'las', 'un', 'una', 'y', 'o', 'a', 'the', 'in', 'on', 'at', 'of', 'and', 'or']);
return new Set(
title
.toLowerCase()
.split(/\s+/)
.filter(word => word.length > 3 && !stopWords.has(word))
);
};
const terms1 = getKeyTerms(alert1.title);
const terms2 = getKeyTerms(alert2.title);
// Calculate similarity: intersection / union
const intersection = new Set([...terms1].filter(x => terms2.has(x)));
const union = new Set([...terms1, ...terms2]);
const similarity = intersection.size / union.size;
return similarity > 0.5; // 50% similarity threshold
}
/**
* Group alerts by time periods
*/
export function groupAlertsByTime(alerts: Alert[]): AlertGroup[] {
const groups: Map<TimeGroup, Alert[]> = new Map();
alerts.forEach(alert => {
const timeGroup = getTimeGroup(alert.created_at);
if (!groups.has(timeGroup)) {
groups.set(timeGroup, []);
}
groups.get(timeGroup)!.push(alert);
});
const timeOrder: TimeGroup[] = ['today', 'yesterday', 'this_week', 'older'];
return timeOrder
.filter(key => groups.has(key))
.map(key => {
const groupAlerts = groups.get(key)!;
const highestSeverity = getHighestSeverity(groupAlerts);
return {
id: `time-${key}`,
type: 'time' as const,
key,
title: getTimeGroupName(key),
count: groupAlerts.length,
severity: highestSeverity,
alerts: groupAlerts,
};
});
}
/**
* Group alerts by category
*/
export function groupAlertsByCategory(alerts: Alert[]): AlertGroup[] {
const groups: Map<AlertCategory, Alert[]> = new Map();
alerts.forEach(alert => {
const category = categorizeAlert(alert);
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(alert);
});
// Sort by count (descending)
const sortedCategories = Array.from(groups.entries())
.sort((a, b) => b[1].length - a[1].length);
return sortedCategories.map(([category, groupAlerts]) => {
const highestSeverity = getHighestSeverity(groupAlerts);
return {
id: `category-${category}`,
type: 'category' as const,
key: category,
title: `${getCategoryIcon(category)} ${getCategoryName(category)}`,
count: groupAlerts.length,
severity: highestSeverity,
alerts: groupAlerts,
};
});
}
/**
* Group similar alerts together
*/
export function groupSimilarAlerts(alerts: Alert[]): AlertGroup[] {
const groups: AlertGroup[] = [];
const processed = new Set<string>();
alerts.forEach(alert => {
if (processed.has(alert.id)) {
return;
}
// Find similar alerts
const similarAlerts = alerts.filter(other =>
!processed.has(other.id) && areAlertsSimilar(alert, other)
);
if (similarAlerts.length > 1) {
// Create a group
similarAlerts.forEach(a => processed.add(a.id));
const category = categorizeAlert(alert);
const highestSeverity = getHighestSeverity(similarAlerts);
groups.push({
id: `similar-${alert.id}`,
type: 'similarity',
key: `${category}-${getSeverity(alert)}`,
title: `${similarAlerts.length} alertas de ${getCategoryName(category).toLowerCase()}`,
count: similarAlerts.length,
severity: highestSeverity,
alerts: similarAlerts,
});
} else {
// Single alert, add as individual group
processed.add(alert.id);
groups.push({
id: `single-${alert.id}`,
type: 'similarity',
key: alert.id,
title: alert.title,
count: 1,
severity: getSeverity(alert) as AlertSeverity,
alerts: [alert],
});
}
});
return groups;
}
/**
* Get highest severity from a list of alerts
*/
export function getHighestSeverity(alerts: Alert[]): AlertSeverity {
const severityOrder: AlertSeverity[] = ['urgent', 'high', 'medium', 'low'];
for (const severity of severityOrder) {
if (alerts.some(alert => getSeverity(alert) === severity)) {
return severity;
}
}
return 'low';
}
/**
* Sort alerts by severity and timestamp
*/
export function sortAlerts(alerts: Alert[]): Alert[] {
const severityOrder: Record<AlertSeverity, number> = {
urgent: 4,
high: 3,
medium: 2,
low: 1,
};
return [...alerts].sort((a, b) => {
// First by severity
const severityDiff = severityOrder[getSeverity(b) as AlertSeverity] - severityOrder[getSeverity(a) as AlertSeverity];
if (severityDiff !== 0) {
return severityDiff;
}
// Then by timestamp (newest first)
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}
/**
* Filter alerts based on criteria
*/
export function filterAlerts(
alerts: Alert[],
filters: AlertFilters,
snoozedAlerts: Map<string, SnoozedAlert>,
t?: TFunction
): Alert[] {
return alerts.filter(alert => {
// Filter by priority
if (filters.priorities.length > 0 && !filters.priorities.includes(alert.priority_level as PriorityLevel)) {
return false;
}
// Filter by category
if (filters.categories.length > 0) {
const category = categorizeAlert(alert);
if (!filters.categories.includes(category)) {
return false;
}
}
// Filter by time range
if (filters.timeRange !== 'all') {
const timeGroup = getTimeGroup(alert.created_at);
if (timeGroup !== filters.timeRange) {
return false;
}
}
// Filter by search text
if (filters.search.trim()) {
const searchLower = filters.search.toLowerCase();
// If translation function is provided, search in translated text
if (t) {
const translatedTitle = translateAlertTitle(alert, t);
const translatedMessage = translateAlertMessage(alert, t);
const searchableText = `${translatedTitle} ${translatedMessage}`.toLowerCase();
if (!searchableText.includes(searchLower)) {
return false;
}
} else {
// Fallback to original title and message
const searchableText = `${alert.title} ${alert.message}`.toLowerCase();
if (!searchableText.includes(searchLower)) {
return false;
}
}
}
// Filter snoozed alerts
if (!filters.showSnoozed) {
const snoozed = snoozedAlerts.get(alert.id);
if (snoozed && snoozed.until > Date.now()) {
return false;
}
}
return true;
});
}
/**
* Check if alert is snoozed
*/
export function isAlertSnoozed(alertId: string, snoozedAlerts: Map<string, SnoozedAlert>): boolean {
const snoozed = snoozedAlerts.get(alertId);
if (!snoozed) {
return false;
}
if (snoozed.until <= Date.now()) {
return false;
}
return true;
}
/**
* Get time remaining for snoozed alert
*/
export function getSnoozedTimeRemaining(alertId: string, snoozedAlerts: Map<string, SnoozedAlert>): string | null {
const snoozed = snoozedAlerts.get(alertId);
if (!snoozed || snoozed.until <= Date.now()) {
return null;
}
const remaining = snoozed.until - Date.now();
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
/**
* Calculate snooze timestamp based on duration
*/
export function calculateSnoozeUntil(duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number): number {
const now = Date.now();
if (typeof duration === 'number') {
return now + duration;
}
switch (duration) {
case '15min':
return now + 15 * 60 * 1000;
case '1hr':
return now + 60 * 60 * 1000;
case '4hr':
return now + 4 * 60 * 60 * 1000;
case 'tomorrow': {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0); // 9 AM tomorrow
return tomorrow.getTime();
}
default:
return now + 60 * 60 * 1000; // default 1 hour
}
}
/**
* Get contextual action for alert type
*/
export interface ContextualAction {
label: string;
icon: string;
variant: 'primary' | 'secondary' | 'outline';
action: string; // action identifier
route?: string; // navigation route
metadata?: Record<string, any>; // Additional action metadata
}
export function getContextualActions(alert: Alert): ContextualAction[] {
const category = categorizeAlert(alert);
const text = `${alert.title} ${alert.message}`.toLowerCase();
const actions: ContextualAction[] = [];
// Category-specific actions
if (category === 'inventory') {
if (text.includes('bajo') || text.includes('low')) {
actions.push({
label: 'Ordenar Stock',
icon: '🛒',
variant: 'primary',
action: 'order_stock',
route: '/app/procurement',
});
}
if (text.includes('caduca') || text.includes('expir')) {
actions.push({
label: 'Planificar Uso',
icon: '📅',
variant: 'primary',
action: 'plan_usage',
route: '/app/production',
});
}
}
if (category === 'equipment') {
actions.push({
label: 'Programar Mantenimiento',
icon: '🔧',
variant: 'primary',
action: 'schedule_maintenance',
route: '/app/operations/maquinaria',
});
}
if (category === 'orders') {
if (text.includes('retraso') || text.includes('delayed')) {
actions.push({
label: 'Contactar Cliente',
icon: '📞',
variant: 'primary',
action: 'contact_customer',
});
}
}
if (category === 'production') {
actions.push({
label: 'Ver Producción',
icon: '🏭',
variant: 'secondary',
action: 'view_production',
route: '/app/production',
});
}
// Always add generic view details action
actions.push({
label: 'Ver Detalles',
icon: '👁️',
variant: 'outline',
action: 'view_details',
});
return actions;
}
/**
* Search alerts with highlighting
*/
export interface SearchMatch {
alert: Alert;
highlights: {
title: boolean;
message: boolean;
};
}
export function searchAlerts(alerts: Alert[], query: string): SearchMatch[] {
if (!query.trim()) {
return alerts.map(alert => ({
alert,
highlights: { title: false, message: false },
}));
}
const searchLower = query.toLowerCase();
return alerts
.filter(alert => {
const titleMatch = alert.title.toLowerCase().includes(searchLower);
const messageMatch = alert.message.toLowerCase().includes(searchLower);
return titleMatch || messageMatch;
})
.map(alert => ({
alert,
highlights: {
title: alert.title.toLowerCase().includes(searchLower),
message: alert.message.toLowerCase().includes(searchLower),
},
}));
}
/**
* Get alert statistics
*/
export interface AlertStats {
total: number;
bySeverity: Record<AlertSeverity, number>;
byCategory: Record<AlertCategory, number>;
unread: number;
snoozed: number;
}
export function getAlertStatistics(
alerts: Alert[],
snoozedAlerts: Map<string, SnoozedAlert>
): AlertStats {
const stats: AlertStats = {
total: alerts.length,
bySeverity: { urgent: 0, high: 0, medium: 0, low: 0 },
byCategory: { inventory: 0, production: 0, orders: 0, equipment: 0, quality: 0, suppliers: 0, other: 0 },
unread: 0,
snoozed: 0,
};
alerts.forEach(alert => {
stats.bySeverity[getSeverity(alert) as AlertSeverity]++;
stats.byCategory[categorizeAlert(alert)]++;
if (alert.status === 'active') {
stats.unread++;
}
if (isAlertSnoozed(alert.id, snoozedAlerts)) {
stats.snoozed++;
}
});
return stats;
}

View File

@@ -14,73 +14,7 @@ export interface AlertI18nData {
message_params?: Record<string, any>;
}
export interface AlertTranslationResult {
title: string;
message: string;
isTranslated: boolean;
}
/**
* Translates alert title and message using i18n data from metadata
*
* @param alert - Alert object with title, message, and metadata
* @param t - i18next translation function
* @returns Translated or fallback title and message
*/
export function translateAlert(
alert: {
title: string;
message: string;
metadata?: Record<string, any>;
},
t: TFunction
): AlertTranslationResult {
// Extract i18n data from metadata
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
// If no i18n data, return original title and message
if (!i18nData || (!i18nData.title_key && !i18nData.message_key)) {
return {
title: alert.title,
message: alert.message,
isTranslated: false,
};
}
// Translate title
let translatedTitle = alert.title;
if (i18nData.title_key) {
try {
const translated = t(i18nData.title_key, i18nData.title_params || {});
// Only use translation if it's not the key itself (i18next returns key if translation missing)
if (translated !== i18nData.title_key) {
translatedTitle = translated;
}
} catch (error) {
console.warn(`Failed to translate alert title with key: ${i18nData.title_key}`, error);
}
}
// Translate message
let translatedMessage = alert.message;
if (i18nData.message_key) {
try {
const translated = t(i18nData.message_key, i18nData.message_params || {});
// Only use translation if it's not the key itself
if (translated !== i18nData.message_key) {
translatedMessage = translated;
}
} catch (error) {
console.warn(`Failed to translate alert message with key: ${i18nData.message_key}`, error);
}
}
return {
title: translatedTitle,
message: translatedMessage,
isTranslated: true,
};
}
/**
* Translates alert title only
@@ -91,23 +25,20 @@ export function translateAlert(
*/
export function translateAlertTitle(
alert: {
title: string;
metadata?: Record<string, any>;
i18n?: AlertI18nData;
},
t: TFunction
): string {
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
if (!i18nData?.title_key) {
return alert.title;
if (!alert.i18n?.title_key) {
return 'Alert';
}
try {
const translated = t(i18nData.title_key, i18nData.title_params || {});
return translated !== i18nData.title_key ? translated : alert.title;
const translated = t(alert.i18n.title_key, alert.i18n.title_params || {});
return translated !== alert.i18n.title_key ? translated : alert.i18n.title_key;
} catch (error) {
console.warn(`Failed to translate alert title with key: ${i18nData.title_key}`, error);
return alert.title;
console.warn(`Failed to translate alert title with key: ${alert.i18n.title_key}`, error);
return alert.i18n.title_key;
}
}
@@ -120,23 +51,20 @@ export function translateAlertTitle(
*/
export function translateAlertMessage(
alert: {
message: string;
metadata?: Record<string, any>;
i18n?: AlertI18nData;
},
t: TFunction
): string {
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
if (!i18nData?.message_key) {
return alert.message;
if (!alert.i18n?.message_key) {
return 'No message';
}
try {
const translated = t(i18nData.message_key, i18nData.message_params || {});
return translated !== i18nData.message_key ? translated : alert.message;
const translated = t(alert.i18n.message_key, alert.i18n.message_params || {});
return translated !== alert.i18n.message_key ? translated : alert.i18n.message_key;
} catch (error) {
console.warn(`Failed to translate alert message with key: ${i18nData.message_key}`, error);
return alert.message;
console.warn(`Failed to translate alert message with key: ${alert.i18n.message_key}`, error);
return alert.i18n.message_key;
}
}
@@ -146,7 +74,8 @@ export function translateAlertMessage(
* @param alert - Alert object
* @returns True if i18n data is present
*/
export function hasI18nData(alert: { metadata?: Record<string, any> }): boolean {
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
return !!(i18nData && (i18nData.title_key || i18nData.message_key));
export function hasI18nData(alert: {
i18n?: AlertI18nData;
}): boolean {
return !!(alert.i18n && (alert.i18n.title_key || alert.i18n.message_key));
}

View File

@@ -0,0 +1,317 @@
/**
* Unified Alert Management System
*
* Comprehensive system for handling all alert operations in the frontend
* including API calls, SSE processing, and UI state management
*/
import { Alert, Event, AlertTypeClass, PriorityLevel, EventDomain } from '../api/types/events';
import { translateAlertTitle, translateAlertMessage } from '../utils/alertI18n';
// ============================================================
// Type Definitions
// ============================================================
export interface AlertFilterOptions {
type_class?: AlertTypeClass[];
priority_level?: PriorityLevel[];
domain?: EventDomain[];
status?: ('active' | 'acknowledged' | 'resolved' | 'dismissed' | 'in_progress')[];
search?: string;
}
export interface AlertProcessingResult {
success: boolean;
alert?: Alert | AlertResponse;
error?: string;
}
// ============================================================
// Alert Processing Utilities
// ============================================================
/**
* Normalize alert to the unified structure (only for new Event structure)
*/
export function normalizeAlert(alert: any): Alert {
// Only accept the new Event structure - no legacy support
if (alert.event_class === 'alert') {
return alert as Alert;
}
// If it's an SSE EventSource message with nested data
if (alert.data && alert.data.event_class === 'alert') {
return alert.data as Alert;
}
throw new Error('Only new Event structure is supported by normalizeAlert');
}
/**
* Apply filters to an array of alerts
*/
export function applyAlertFilters(
alerts: Alert[],
filters: AlertFilterOptions = {},
search: string = ''
): Alert[] {
return alerts.filter(alert => {
// Filter by type class
if (filters.type_class && filters.type_class.length > 0) {
if (!alert.type_class || !filters.type_class.includes(alert.type_class as AlertTypeClass)) {
return false;
}
}
// Filter by priority level
if (filters.priority_level && filters.priority_level.length > 0) {
if (!alert.priority_level || !filters.priority_level.includes(alert.priority_level as PriorityLevel)) {
return false;
}
}
// Filter by domain
if (filters.domain && filters.domain.length > 0) {
if (!alert.event_domain || !filters.domain.includes(alert.event_domain as EventDomain)) {
return false;
}
}
// Filter by status
if (filters.status && filters.status.length > 0) {
if (!alert.status || !filters.status.includes(alert.status as any)) {
return false;
}
}
// Search filter
if (search) {
const searchLower = search.toLowerCase();
const title = translateAlertTitle(alert, (key: string, params?: any) => key) || '';
const message = translateAlertMessage(alert, (key: string, params?: any) => key) || '';
if (!title.toLowerCase().includes(searchLower) &&
!message.toLowerCase().includes(searchLower) &&
!alert.id.toLowerCase().includes(searchLower)) {
return false;
}
}
return true;
});
}
// ============================================================
// Alert Filtering and Sorting
// ============================================================
/**
* Filter alerts based on provided criteria
*/
export function filterAlerts(alerts: Alert[], filters: AlertFilterOptions = {}): Alert[] {
return alerts.filter(alert => {
// Type class filter
if (filters.type_class && !filters.type_class.includes(alert.type_class)) {
return false;
}
// Priority level filter
if (filters.priority_level && !filters.priority_level.includes(alert.priority_level)) {
return false;
}
// Domain filter
if (filters.domain && !filters.domain.includes(alert.event_domain)) {
return false;
}
// Status filter
if (filters.status && !filters.status.includes(alert.status as any)) {
return false;
}
// Search filter
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const title = translateAlertTitle(alert, (key: string, params?: any) => key).toLowerCase();
const message = translateAlertMessage(alert, (key: string, params?: any) => key).toLowerCase();
if (!title.includes(searchTerm) && !message.includes(searchTerm)) {
return false;
}
}
return true;
});
}
/**
* Sort alerts by priority, urgency, and creation time
*/
export function sortAlerts(alerts: Alert[]): Alert[] {
return [...alerts].sort((a, b) => {
// Sort by priority level first
const priorityOrder: Record<PriorityLevel, number> = {
critical: 4,
important: 3,
standard: 2,
info: 1
};
const priorityDiff = priorityOrder[b.priority_level] - priorityOrder[a.priority_level];
if (priorityDiff !== 0) return priorityDiff;
// If same priority, sort by type class
const typeClassOrder: Record<AlertTypeClass, number> = {
escalation: 5,
action_needed: 4,
prevented_issue: 3,
trend_warning: 2,
information: 1
};
const typeDiff = typeClassOrder[b.type_class] - typeClassOrder[a.type_class];
if (typeDiff !== 0) return typeDiff;
// If same type and priority, sort by creation time (newest first)
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}
// ============================================================
// Alert Utility Functions
// ============================================================
/**
* Get alert icon based on type and priority
*/
export function getAlertIcon(alert: Alert): string {
switch (alert.type_class) {
case 'action_needed':
return alert.priority_level === 'critical' ? 'alert-triangle' : 'alert-circle';
case 'escalation':
return 'alert-triangle';
case 'trend_warning':
return 'trending-up';
case 'prevented_issue':
return 'check-circle';
case 'information':
default:
return 'info';
}
}
/**
* Get alert color based on priority level
*/
export function getAlertColor(alert: Alert): string {
switch (alert.priority_level) {
case 'critical':
return 'var(--color-error)';
case 'important':
return 'var(--color-warning)';
case 'standard':
return 'var(--color-info)';
case 'info':
default:
return 'var(--color-success)';
}
}
/**
* Check if alert requires immediate attention
*/
export function requiresImmediateAttention(alert: Alert): boolean {
return alert.type_class === 'action_needed' &&
(alert.priority_level === 'critical' || alert.priority_level === 'important') &&
alert.status === 'active';
}
/**
* Check if alert is actionable (not already addressed)
*/
export function isActionable(alert: Alert): boolean {
return alert.status === 'active' &&
!alert.orchestrator_context?.already_addressed;
}
// ============================================================
// SSE Processing
// ============================================================
// ============================================================
// Alert State Management Utilities
// ============================================================
/**
* Merge new alerts with existing alerts, avoiding duplicates
*/
export function mergeAlerts(existingAlerts: Alert[], newAlerts: Alert[]): Alert[] {
const existingIds = new Set(existingAlerts.map(alert => alert.id));
const uniqueNewAlerts = newAlerts.filter(alert => !existingIds.has(alert.id));
return [...existingAlerts, ...uniqueNewAlerts];
}
/**
* Update specific alert in array (for status changes, etc.)
*/
export function updateAlertInArray(alerts: Alert[], updatedAlert: Alert): Alert[] {
return alerts.map(alert =>
alert.id === updatedAlert.id ? updatedAlert : alert
);
}
/**
* Remove specific alert from array
*/
export function removeAlertFromArray(alerts: Alert[], alertId: string): Alert[] {
return alerts.filter(alert => alert.id !== alertId);
}
/**
* Get alert statistics
*/
export function getAlertStats(alerts: Alert[]) {
const stats = {
total: alerts.length,
active: 0,
acknowledged: 0,
resolved: 0,
critical: 0,
important: 0,
standard: 0,
info: 0,
actionNeeded: 0,
preventedIssue: 0,
trendWarning: 0,
escalation: 0,
information: 0
};
alerts.forEach(alert => {
switch (alert.status) {
case 'active': stats.active++; break;
case 'acknowledged': stats.acknowledged++; break;
case 'resolved': stats.resolved++; break;
}
switch (alert.priority_level) {
case 'critical': stats.critical++; break;
case 'important': stats.important++; break;
case 'standard': stats.standard++; break;
case 'info': stats.info++; break;
}
switch (alert.type_class) {
case 'action_needed': stats.actionNeeded++; break;
case 'prevented_issue': stats.preventedIssue++; break;
case 'trend_warning': stats.trendWarning++; break;
case 'escalation': stats.escalation++; break;
case 'information': stats.information++; break;
}
});
return stats;
}

View File

@@ -0,0 +1,178 @@
/**
* Clean i18n Parameter System for Event Content in Frontend
*
* Handles rendering of parameterized content for:
* - Alert titles and messages
* - Notification titles and messages
* - Recommendation titles and messages
* - AI reasoning summaries
* - Action labels and consequences
*/
import { I18nContent, Event, Alert, Notification, Recommendation, SmartAction } from '../api/types/events';
import { useTranslation } from 'react-i18next';
interface I18nRenderer {
renderTitle: (titleKey: string, titleParams?: Record<string, any>) => string;
renderMessage: (messageKey: string, messageParams?: Record<string, any>) => string;
renderReasoningSummary: (summaryKey: string, summaryParams?: Record<string, any>) => string;
renderActionLabel: (labelKey: string, labelParams?: Record<string, any>) => string;
renderUrgencyReason: (reasonKey: string, reasonParams?: Record<string, any>) => string;
}
/**
* Render a parameterized template with given parameters
*/
export const renderTemplate = (template: string, params: Record<string, any> = {}): string => {
if (!template) return '';
let result = template;
for (const [key, value] of Object.entries(params)) {
// Replace {{key}} with the value, handling nested properties
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
result = result.replace(regex, String(value ?? ''));
}
return result;
};
/**
* Hook for accessing the i18n renderer within React components
*/
export const useEventI18n = (): I18nRenderer => {
const { t } = useTranslation(['events', 'common']);
const renderTitle = (titleKey: string, titleParams: Record<string, any> = {}): string => {
return t(titleKey, { defaultValue: titleKey, ...titleParams });
};
const renderMessage = (messageKey: string, messageParams: Record<string, any> = {}): string => {
return t(messageKey, { defaultValue: messageKey, ...messageParams });
};
const renderReasoningSummary = (summaryKey: string, summaryParams: Record<string, any> = {}): string => {
return t(summaryKey, { defaultValue: summaryKey, ...summaryParams });
};
const renderActionLabel = (labelKey: string, labelParams: Record<string, any> = {}): string => {
return t(labelKey, { defaultValue: labelKey, ...labelParams });
};
const renderUrgencyReason = (reasonKey: string, reasonParams: Record<string, any> = {}): string => {
return t(reasonKey, { defaultValue: reasonKey, ...reasonParams });
};
return {
renderTitle,
renderMessage,
renderReasoningSummary,
renderActionLabel,
renderUrgencyReason
};
};
/**
* Render i18n content for an event
*/
export const renderEventContent = (i18n: I18nContent, language?: string): { title: string; message: string } => {
const title = renderTemplate(i18n.title_key, i18n.title_params);
const message = renderTemplate(i18n.message_key, i18n.message_params);
return { title, message };
};
/**
* Render all content for an alert
*/
export const renderAlertContent = (alert: Alert, language?: string) => {
const { title, message } = renderEventContent(alert.i18n, language);
let reasoningSummary = '';
if (alert.ai_reasoning?.summary_key) {
reasoningSummary = renderTemplate(
alert.ai_reasoning.summary_key,
alert.ai_reasoning.summary_params
);
}
// Render smart actions with parameterized labels
const renderedActions = alert.smart_actions.map(action => ({
...action,
label: renderTemplate(action.label_key, action.label_params),
consequence: action.consequence_key
? renderTemplate(action.consequence_key, action.consequence_params)
: undefined,
disabled_reason: action.disabled_reason_key
? renderTemplate(action.disabled_reason_key, action.disabled_reason_params)
: action.disabled_reason
}));
return {
title,
message,
reasoningSummary,
renderedActions
};
};
/**
* Render all content for a notification
*/
export const renderNotificationContent = (notification: Notification, language?: string) => {
const { title, message } = renderEventContent(notification.i18n, language);
return {
title,
message
};
};
/**
* Render all content for a recommendation
*/
export const renderRecommendationContent = (recommendation: Recommendation, language?: string) => {
const { title, message } = renderEventContent(recommendation.i18n, language);
let reasoningSummary = '';
if (recommendation.ai_reasoning?.summary_key) {
reasoningSummary = renderTemplate(
recommendation.ai_reasoning.summary_key,
recommendation.ai_reasoning.summary_params
);
}
// Render suggested actions with parameterized labels
const renderedSuggestedActions = recommendation.suggested_actions.map(action => ({
...action,
label: renderTemplate(action.label_key, action.label_params),
consequence: action.consequence_key
? renderTemplate(action.consequence_key, action.consequence_params)
: undefined,
disabled_reason: action.disabled_reason_key
? renderTemplate(action.disabled_reason_key, action.disabled_reason_params)
: action.disabled_reason
}));
return {
title,
message,
reasoningSummary,
renderedSuggestedActions
};
};
/**
* Render content for any event type
*/
export const renderEvent = (event: Event, language?: string) => {
switch (event.event_class) {
case 'alert':
return renderAlertContent(event as Alert, language);
case 'notification':
return renderNotificationContent(event as Notification, language);
case 'recommendation':
return renderRecommendationContent(event as Recommendation, language);
default:
throw new Error(`Unknown event class: ${(event as any).event_class}`);
}
};

View File

@@ -0,0 +1,366 @@
/**
* Alert Rendering Utilities - i18n Parameter Substitution
*
* Centralized rendering functions for alert system with proper i18n support.
* Uses new type system from /api/types/events.ts
*/
import { TFunction } from 'i18next';
import type {
EventResponse,
Alert,
Notification,
Recommendation,
SmartAction,
UrgencyContext,
I18nDisplayContext,
AIReasoningContext,
isAlert,
isNotification,
isRecommendation,
} from '../../api/types/events';
// ============================================================
// EVENT CONTENT RENDERING
// ============================================================
/**
* Render event title with parameter substitution
*/
export function renderEventTitle(
event: EventResponse,
t: TFunction
): string {
try {
const { title_key, title_params } = event.i18n;
return t(title_key, title_params || {});
} catch (error) {
console.error('Error rendering event title:', error);
return event.i18n.title_key || 'Untitled Event';
}
}
/**
* Render event message with parameter substitution
*/
export function renderEventMessage(
event: EventResponse,
t: TFunction
): string {
try {
const { message_key, message_params } = event.i18n;
return t(message_key, message_params || {});
} catch (error) {
console.error('Error rendering event message:', error);
return event.i18n.message_key || 'No message available';
}
}
// ============================================================
// SMART ACTION RENDERING
// ============================================================
/**
* Render action label with parameter substitution
*/
export function renderActionLabel(
action: SmartAction,
t: TFunction
): string {
try {
return t(action.label_key, action.label_params || {});
} catch (error) {
console.error('Error rendering action label:', error);
return action.label_key || 'Action';
}
}
/**
* Render action consequence with parameter substitution
*/
export function renderActionConsequence(
action: SmartAction,
t: TFunction
): string | null {
if (!action.consequence_key) return null;
try {
return t(action.consequence_key, action.consequence_params || {});
} catch (error) {
console.error('Error rendering action consequence:', error);
return null;
}
}
/**
* Render disabled reason with parameter substitution
*/
export function renderDisabledReason(
action: SmartAction,
t: TFunction
): string | null {
// Try i18n key first
if (action.disabled_reason_key) {
try {
return t(action.disabled_reason_key, action.disabled_reason_params || {});
} catch (error) {
console.error('Error rendering disabled reason:', error);
}
}
// Fallback to plain text
return action.disabled_reason || null;
}
// ============================================================
// AI REASONING RENDERING
// ============================================================
/**
* Render AI reasoning summary with parameter substitution
*/
export function renderAIReasoning(
event: Alert,
t: TFunction
): string | null {
if (!event.ai_reasoning?.summary_key) return null;
try {
return t(
event.ai_reasoning.summary_key,
event.ai_reasoning.summary_params || {}
);
} catch (error) {
console.error('Error rendering AI reasoning:', error);
return null;
}
}
// ============================================================
// URGENCY CONTEXT RENDERING
// ============================================================
/**
* Render urgency reason with parameter substitution
*/
export function renderUrgencyReason(
urgency: UrgencyContext,
t: TFunction
): string | null {
if (!urgency.urgency_reason_key) return null;
try {
return t(urgency.urgency_reason_key, urgency.urgency_reason_params || {});
} catch (error) {
console.error('Error rendering urgency reason:', error);
return null;
}
}
// ============================================================
// SAFE RENDERING WITH FALLBACKS
// ============================================================
/**
* Safely render any i18n context with fallback
*/
export function safeRenderI18n(
key: string | undefined,
params: Record<string, any> | undefined,
t: TFunction,
fallback: string = ''
): string {
if (!key) return fallback;
try {
return t(key, params || {});
} catch (error) {
console.error(`Error rendering i18n key ${key}:`, error);
return fallback || key;
}
}
// ============================================================
// EVENT TYPE HELPERS
// ============================================================
/**
* Get event type display name
*/
export function getEventTypeLabel(event: EventResponse, t: TFunction): string {
if (isAlert(event)) {
return t('common.event_types.alert', 'Alert');
} else if (isNotification(event)) {
return t('common.event_types.notification', 'Notification');
} else if (isRecommendation(event)) {
return t('common.event_types.recommendation', 'Recommendation');
}
return t('common.event_types.unknown', 'Event');
}
/**
* Get priority level display name
*/
export function getPriorityLevelLabel(
level: string,
t: TFunction
): string {
const key = `common.priority_levels.${level}`;
return t(key, level.charAt(0).toUpperCase() + level.slice(1));
}
/**
* Get status display name
*/
export function getStatusLabel(status: string, t: TFunction): string {
const key = `common.statuses.${status}`;
return t(key, status.charAt(0).toUpperCase() + level.slice(1));
}
// ============================================================
// FORMATTING HELPERS
// ============================================================
/**
* Format countdown time (for escalation alerts)
*/
export function formatCountdown(seconds: number | undefined): string {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
/**
* Format time until consequence (hours)
*/
export function formatTimeUntilConsequence(hours: number | undefined, t: TFunction): string {
if (!hours) return '';
if (hours < 1) {
const minutes = Math.round(hours * 60);
return t('common.time.minutes', { count: minutes }, `${minutes} minutes`);
} else if (hours < 24) {
const roundedHours = Math.round(hours);
return t('common.time.hours', { count: roundedHours }, `${roundedHours} hours`);
} else {
const days = Math.round(hours / 24);
return t('common.time.days', { count: days }, `${days} days`);
}
}
/**
* Format deadline as relative time
*/
export function formatDeadline(deadline: string | undefined, t: TFunction): string {
if (!deadline) return '';
try {
const deadlineDate = new Date(deadline);
const now = new Date();
const diffMs = deadlineDate.getTime() - now.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
if (diffHours < 0) {
return t('common.time.overdue', 'Overdue');
}
return formatTimeUntilConsequence(diffHours, t);
} catch (error) {
console.error('Error formatting deadline:', error);
return '';
}
}
// ============================================================
// CURRENCY FORMATTING
// ============================================================
/**
* Format currency value from params
*/
export function formatCurrency(value: number | undefined, currency: string = 'EUR'): string {
if (value === undefined || value === null) return '';
try {
return new Intl.NumberFormat('en-EU', {
style: 'currency',
currency,
}).format(value);
} catch (error) {
return `${value} ${currency}`;
}
}
// ============================================================
// COMPLETE EVENT RENDERING
// ============================================================
/**
* Render complete event with all i18n content
*/
export interface RenderedEvent {
title: string;
message: string;
actions: Array<{
label: string;
consequence: string | null;
disabledReason: string | null;
original: SmartAction;
}>;
aiReasoning: string | null;
urgencyReason: string | null;
}
/**
* Render all event content at once
*/
export function renderCompleteEvent(
event: EventResponse,
t: TFunction
): RenderedEvent {
const rendered: RenderedEvent = {
title: renderEventTitle(event, t),
message: renderEventMessage(event, t),
actions: [],
aiReasoning: null,
urgencyReason: null,
};
// Render actions (only for alerts)
if (isAlert(event)) {
rendered.actions = event.smart_actions.map((action) => ({
label: renderActionLabel(action, t),
consequence: renderActionConsequence(action, t),
disabledReason: renderDisabledReason(action, t),
original: action,
}));
rendered.aiReasoning = renderAIReasoning(event, t);
if (event.urgency) {
rendered.urgencyReason = renderUrgencyReason(event.urgency, t);
}
}
// Render suggested actions (only for recommendations)
if (isRecommendation(event) && event.suggested_actions) {
rendered.actions = event.suggested_actions.map((action) => ({
label: renderActionLabel(action, t),
consequence: renderActionConsequence(action, t),
disabledReason: renderDisabledReason(action, t),
original: action,
}));
}
return rendered;
}

View File

@@ -1,37 +1,23 @@
/**
* Smart Action Handlers - Complete Implementation
* Handles execution of all 14 smart action types from enriched alerts
* Handles execution of all smart action types from enriched alerts
*
* NO PLACEHOLDERS - All action types fully implemented
*/
import { useNavigate } from 'react-router-dom';
import { SmartAction as ImportedSmartAction, SmartActionType } from '../api/types/events';
// ============================================================
// Types (matching backend SmartActionType enum)
// Types (using imported types from events.ts)
// ============================================================
export enum SmartActionType {
APPROVE_PO = 'approve_po',
REJECT_PO = 'reject_po',
MODIFY_PO = 'modify_po',
CALL_SUPPLIER = 'call_supplier',
NAVIGATE = 'navigate',
ADJUST_PRODUCTION = 'adjust_production',
START_PRODUCTION_BATCH = 'start_production_batch',
NOTIFY_CUSTOMER = 'notify_customer',
CANCEL_AUTO_ACTION = 'cancel_auto_action',
MARK_DELIVERY_RECEIVED = 'mark_delivery_received',
COMPLETE_STOCK_RECEIPT = 'complete_stock_receipt',
OPEN_REASONING = 'open_reasoning',
SNOOZE = 'snooze',
DISMISS = 'dismiss',
MARK_READ = 'mark_read',
}
// Legacy interface for backwards compatibility with existing handler code
export interface SmartAction {
label: string;
type: SmartActionType;
label?: string;
label_key?: string;
action_type: string;
type?: string; // For backward compatibility
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
metadata?: Record<string, any>;
disabled?: boolean;
@@ -40,6 +26,9 @@ export interface SmartAction {
consequence?: string;
}
// Re-export types from events.ts
export { SmartActionType };
// ============================================================
// Smart Action Handler Class
// ============================================================
@@ -65,7 +54,10 @@ export class SmartActionHandler {
try {
let result = false;
switch (action.type) {
// Support both legacy (type) and new (action_type) field names
const actionType = action.action_type || action.type;
switch (actionType) {
case SmartActionType.APPROVE_PO:
result = await this.handleApprovePO(action);
break;
@@ -78,6 +70,10 @@ export class SmartActionHandler {
result = this.handleModifyPO(action);
break;
case SmartActionType.VIEW_PO_DETAILS:
result = this.handleViewPODetails(action);
break;
case SmartActionType.CALL_SUPPLIER:
result = this.handleCallSupplier(action);
break;
@@ -127,8 +123,8 @@ export class SmartActionHandler {
break;
default:
console.warn('Unknown action type:', action.type);
this.onError?.(`Unknown action type: ${action.type}`);
console.warn('Unknown action type:', actionType);
this.onError?.(`Unknown action type: ${actionType}`);
return false;
}
@@ -269,6 +265,28 @@ export class SmartActionHandler {
return true;
}
/**
* 3.5. VIEW_PO_DETAILS - Open PO in view mode
*/
private handleViewPODetails(action: SmartAction): boolean {
const { po_id, tenant_id } = action.metadata || {};
if (!po_id) {
console.error('Missing PO ID');
this.onError?.('Missing PO ID for viewing details');
return false;
}
// Emit event to open PO modal in view mode
window.dispatchEvent(
new CustomEvent('po:open-details', {
detail: { po_id, tenant_id, mode: 'view' },
})
);
return true;
}
/**
* 4. CALL_SUPPLIER - Initiate phone call
*/