New alert service
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
354
frontend/src/api/hooks/useAlerts.ts
Normal file
354
frontend/src/api/hooks/useAlerts.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
451
frontend/src/api/hooks/useEnterpriseDashboard.ts
Normal file
451
frontend/src/api/hooks/useEnterpriseDashboard.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
97
frontend/src/api/hooks/useInventoryStatus.ts
Normal file
97
frontend/src/api/hooks/useInventoryStatus.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
81
frontend/src/api/hooks/useProductionBatches.ts
Normal file
81
frontend/src/api/hooks/useProductionBatches.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
1517
frontend/src/api/hooks/useProfessionalDashboard.ts
Normal file
1517
frontend/src/api/hooks/useProfessionalDashboard.ts
Normal file
File diff suppressed because it is too large
Load Diff
154
frontend/src/api/hooks/useUnifiedAlerts.ts
Normal file
154
frontend/src/api/hooks/useUnifiedAlerts.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
253
frontend/src/api/services/alertService.ts
Normal file
253
frontend/src/api/services/alertService.ts
Normal 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;
|
||||
@@ -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;
|
||||
62
frontend/src/api/services/distribution.ts
Normal file
62
frontend/src/api/services/distribution.ts
Normal 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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// ===================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
457
frontend/src/api/types/events.ts
Normal file
457
frontend/src/api/types/events.ts
Normal 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;
|
||||
}
|
||||
117
frontend/src/api/types/orchestrator.ts
Normal file
117
frontend/src/api/types/orchestrator.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
42
frontend/src/config/services.ts
Normal file
42
frontend/src/config/services.ts
Normal 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}`);
|
||||
}
|
||||
127
frontend/src/constants/blog.ts
Normal file
127
frontend/src/constants/blog.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
];
|
||||
74
frontend/src/contexts/AlertContext.tsx
Normal file
74
frontend/src/contexts/AlertContext.tsx
Normal 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;
|
||||
117
frontend/src/contexts/EnterpriseContext.tsx
Normal file
117
frontend/src/contexts/EnterpriseContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
154
frontend/src/contexts/EventContext.tsx
Normal file
154
frontend/src/contexts/EventContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
185
frontend/src/pages/public/BlogPostPage.tsx
Normal file
185
frontend/src/pages/public/BlogPostPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
49
frontend/src/pages/public/UnauthorizedPage.tsx
Normal file
49
frontend/src/pages/public/UnauthorizedPage.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
317
frontend/src/utils/alertManagement.ts
Normal file
317
frontend/src/utils/alertManagement.ts
Normal 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;
|
||||
}
|
||||
178
frontend/src/utils/eventI18n.ts
Normal file
178
frontend/src/utils/eventI18n.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
366
frontend/src/utils/i18n/alertRendering.ts
Normal file
366
frontend/src/utils/i18n/alertRendering.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user