New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -48,14 +48,42 @@ def python_live_update(service_name, service_path):
# ============================================================================= # =============================================================================
# FRONTEND (React + Vite) # FRONTEND (React + Vite)
# ============================================================================= # =============================================================================
# Multi-stage build with optimized memory settings for large Vite builds
# Set FRONTEND_DEBUG=true environment variable to build with development mode (no minification)
# for easier debugging of React errors
# Check for FRONTEND_DEBUG environment variable (Starlark uses os.getenv)
frontend_debug_env = os.getenv('FRONTEND_DEBUG', 'false')
frontend_debug = frontend_debug_env.lower() == 'true'
# Log the build mode
if frontend_debug:
print("""
🐛 FRONTEND DEBUG MODE ENABLED
Building frontend with NO minification for easier debugging.
Full React error messages will be displayed.
To disable: unset FRONTEND_DEBUG or set FRONTEND_DEBUG=false
""")
else:
print("""
📦 FRONTEND PRODUCTION MODE
Building frontend with minification for optimized performance.
To enable debug mode: export FRONTEND_DEBUG=true
""")
docker_build( docker_build(
'bakery/dashboard', 'bakery/dashboard',
context='./frontend', context='./frontend',
dockerfile='./frontend/Dockerfile.kubernetes', dockerfile='./frontend/Dockerfile.kubernetes.debug' if frontend_debug else './frontend/Dockerfile.kubernetes',
# Remove target to build the full image, including the build stage
live_update=[ live_update=[
sync('./frontend/src', '/app/src'), sync('./frontend/src', '/app/src'),
sync('./frontend/public', '/app/public'), sync('./frontend/public', '/app/public'),
], ],
# Increase Node.js memory for build stage
build_args={
'NODE_OPTIONS': '--max-old-space-size=8192'
},
# Ignore test artifacts and reports # Ignore test artifacts and reports
ignore=[ ignore=[
'playwright-report/**', 'playwright-report/**',
@@ -157,7 +185,7 @@ build_python_service('production-service', 'production')
build_python_service('procurement-service', 'procurement') # NEW: Sprint 3 build_python_service('procurement-service', 'procurement') # NEW: Sprint 3
build_python_service('orchestrator-service', 'orchestrator') # NEW: Sprint 2 build_python_service('orchestrator-service', 'orchestrator') # NEW: Sprint 2
build_python_service('ai-insights-service', 'ai_insights') # NEW: AI Insights Platform build_python_service('ai-insights-service', 'ai_insights') # NEW: AI Insights Platform
build_python_service('alert-processor', 'alert_processor') build_python_service('alert-processor', 'alert_processor') # Unified Alert Service with enrichment
build_python_service('demo-session-service', 'demo_session') build_python_service('demo-session-service', 'demo_session')
# ============================================================================= # =============================================================================
@@ -181,7 +209,7 @@ k8s_resource('production-db', resource_deps=['security-setup'], labels=['databas
k8s_resource('procurement-db', resource_deps=['security-setup'], labels=['databases']) # NEW: Sprint 3 k8s_resource('procurement-db', resource_deps=['security-setup'], labels=['databases']) # NEW: Sprint 3
k8s_resource('orchestrator-db', resource_deps=['security-setup'], labels=['databases']) # NEW: Sprint 2 k8s_resource('orchestrator-db', resource_deps=['security-setup'], labels=['databases']) # NEW: Sprint 2
k8s_resource('ai-insights-db', resource_deps=['security-setup'], labels=['databases']) # NEW: AI Insights Platform k8s_resource('ai-insights-db', resource_deps=['security-setup'], labels=['databases']) # NEW: AI Insights Platform
k8s_resource('alert-processor-db', resource_deps=['security-setup'], labels=['databases']) k8s_resource('alert-processor-db', resource_deps=['security-setup'], labels=['databases']) # Unified Alert Service
k8s_resource('demo-session-db', resource_deps=['security-setup'], labels=['databases']) k8s_resource('demo-session-db', resource_deps=['security-setup'], labels=['databases'])
k8s_resource('redis', resource_deps=['security-setup'], labels=['infrastructure']) k8s_resource('redis', resource_deps=['security-setup'], labels=['infrastructure'])
@@ -373,6 +401,11 @@ k8s_resource('demo-seed-orchestration-runs',
resource_deps=['orchestrator-migration', 'demo-seed-tenants'], resource_deps=['orchestrator-migration', 'demo-seed-tenants'],
labels=['demo-init']) labels=['demo-init'])
# Weight 28: Seed alerts (alert processor service) - after orchestration runs as alerts reference recent data
k8s_resource('demo-seed-alerts',
resource_deps=['alert-processor-migration', 'demo-seed-tenants'],
labels=['demo-init'])
k8s_resource('demo-seed-pos-configs', k8s_resource('demo-seed-pos-configs',
resource_deps=['demo-seed-tenants'], resource_deps=['demo-seed-tenants'],
labels=['demo-init']) labels=['demo-init'])
@@ -526,8 +559,8 @@ k8s_resource('frontend',
# Update check interval - how often Tilt checks for file changes # Update check interval - how often Tilt checks for file changes
update_settings( update_settings(
max_parallel_updates=3, max_parallel_updates=2, # Reduce parallel updates to avoid resource exhaustion on local machines
k8s_upsert_timeout_secs=60 k8s_upsert_timeout_secs=120 # Increase timeout for slower local builds
) )
# Watch settings - configure file watching behavior # Watch settings - configure file watching behavior

41
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
# Production build output (should be recreated during Docker build)
dist
# Testing
coverage
test-results
playwright-report
# Logs
logs
*.log
# Environment variables
.env*
!.env.example
# IDE files
.vscode
.idea
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Build outputs
build
.nyc_output
# Local configuration files
.git
.gitignore

View File

@@ -9,15 +9,31 @@ WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies including dev dependencies for building # Install all dependencies for building
RUN npm ci --verbose && \ RUN npm ci --verbose && \
npm cache clean --force npm cache clean --force
# Copy source code # Copy source code (excluding unnecessary files like node_modules, dist, etc.)
COPY . . COPY . .
# Build the application for production # Create a default runtime config in the public directory if it doesn't exist to satisfy the reference in index.html
# This will use environment variables available at build time RUN if [ ! -f public/runtime-config.js ]; then \
mkdir -p public && \
echo "window.__RUNTIME_CONFIG__ = {};" > public/runtime-config.js; \
fi
# Set build-time environment variables to prevent hanging on undefined variables
ENV NODE_ENV=production
ENV CI=true
ENV VITE_API_URL=/api
ENV VITE_APP_TITLE="BakeWise"
ENV VITE_APP_VERSION="1.0.0"
ENV VITE_PILOT_MODE_ENABLED="false"
ENV VITE_PILOT_COUPON_CODE="PILOT2025"
ENV VITE_PILOT_TRIAL_MONTHS="3"
ENV VITE_STRIPE_PUBLISHABLE_KEY="pk_test_"
# Set Node.js memory limit for the build process
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm run build RUN npm run build
# Stage 2: Production server with Nginx # Stage 2: Production server with Nginx
@@ -26,6 +42,9 @@ FROM nginx:1.25-alpine AS production
# Install curl for health checks # Install curl for health checks
RUN apk add --no-cache curl RUN apk add --no-cache curl
# Copy main nginx configuration that sets the PID file location
COPY nginx-main.conf /etc/nginx/nginx.conf
# Remove default nginx configuration # Remove default nginx configuration
RUN rm /etc/nginx/conf.d/default.conf RUN rm /etc/nginx/conf.d/default.conf
@@ -49,18 +68,7 @@ RUN chown -R nginx:nginx /usr/share/nginx/html && \
# Create nginx PID directory and fix permissions # Create nginx PID directory and fix permissions
RUN mkdir -p /var/run/nginx /var/lib/nginx/tmp && \ RUN mkdir -p /var/run/nginx /var/lib/nginx/tmp && \
chown -R nginx:nginx /var/run/nginx /var/lib/nginx chown -R nginx:nginx /var/run/nginx /var/lib/nginx /etc/nginx
# Custom nginx.conf for running as non-root
RUN echo 'pid /var/run/nginx/nginx.pid;' > /etc/nginx/nginx.conf && \
echo 'events { worker_connections 1024; }' >> /etc/nginx/nginx.conf && \
echo 'http {' >> /etc/nginx/nginx.conf && \
echo ' include /etc/nginx/mime.types;' >> /etc/nginx/nginx.conf && \
echo ' default_type application/octet-stream;' >> /etc/nginx/nginx.conf && \
echo ' sendfile on;' >> /etc/nginx/nginx.conf && \
echo ' keepalive_timeout 65;' >> /etc/nginx/nginx.conf && \
echo ' include /etc/nginx/conf.d/*.conf;' >> /etc/nginx/nginx.conf && \
echo '}' >> /etc/nginx/nginx.conf
# Switch to non-root user # Switch to non-root user
USER nginx USER nginx
@@ -74,3 +82,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
# Start nginx # Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,89 @@
# Kubernetes-optimized DEBUG Dockerfile for Frontend
# Multi-stage build for DEVELOPMENT/DEBUG deployment
# This build DISABLES minification and provides full React error messages
# Stage 1: Build the application in DEVELOPMENT MODE
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies for building
RUN npm ci --verbose && \
npm cache clean --force
# Copy source code (excluding unnecessary files like node_modules, dist, etc.)
COPY . .
# Create a default runtime config in the public directory if it doesn't exist to satisfy the reference in index.html
RUN if [ ! -f public/runtime-config.js ]; then \
mkdir -p public && \
echo "window.__RUNTIME_CONFIG__ = {};" > public/runtime-config.js; \
fi
# DEBUG BUILD SETTINGS - NO MINIFICATION
# This will produce larger bundles but with full error messages
ENV NODE_ENV=development
ENV CI=true
ENV VITE_API_URL=/api
ENV VITE_APP_TITLE="BakeWise (Debug)"
ENV VITE_APP_VERSION="1.0.0-debug"
ENV VITE_PILOT_MODE_ENABLED="false"
ENV VITE_PILOT_COUPON_CODE="PILOT2025"
ENV VITE_PILOT_TRIAL_MONTHS="3"
ENV VITE_STRIPE_PUBLISHABLE_KEY="pk_test_"
# Set Node.js memory limit for the build process
ENV NODE_OPTIONS="--max-old-space-size=4096"
# Build in development mode (no minification, full source maps)
RUN npm run build -- --mode development
# Stage 2: Production server with Nginx (same as production)
FROM nginx:1.25-alpine AS production
# Install curl for health checks
RUN apk add --no-cache curl
# Copy main nginx configuration that sets the PID file location
COPY nginx-main.conf /etc/nginx/nginx.conf
# Remove default nginx configuration
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy and setup environment substitution script
COPY substitute-env.sh /docker-entrypoint.d/30-substitute-env.sh
# Make the script executable
RUN chmod +x /docker-entrypoint.d/30-substitute-env.sh
# Set proper permissions
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d
# Create nginx PID directory and fix permissions
RUN mkdir -p /var/run/nginx /var/lib/nginx/tmp && \
chown -R nginx:nginx /var/run/nginx /var/lib/nginx /etc/nginx
# Switch to non-root user
USER nginx
# Expose port 3000 (to match current setup)
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

12
frontend/nginx-main.conf Normal file
View File

@@ -0,0 +1,12 @@
pid /var/run/nginx/nginx.pid;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -20,11 +20,13 @@ import { apiClient } from '../client';
// ============================================================ // ============================================================
export interface HealthChecklistItem { export interface HealthChecklistItem {
icon: 'check' | 'warning' | 'alert'; icon: 'check' | 'warning' | 'alert' | 'ai_handled';
text?: string; // Deprecated: Use textKey instead text?: string; // Deprecated: Use textKey instead
textKey?: string; // i18n key for translation textKey?: string; // i18n key for translation
textParams?: Record<string, any>; // Parameters for i18n translation textParams?: Record<string, any>; // Parameters for i18n translation
actionRequired: boolean; actionRequired: boolean;
status: 'good' | 'ai_handled' | 'needs_you'; // Tri-state status
actionPath?: string; // Optional path to navigate for action
} }
export interface HeadlineData { export interface HeadlineData {
@@ -40,6 +42,7 @@ export interface BakeryHealthStatus {
checklistItems: HealthChecklistItem[]; checklistItems: HealthChecklistItem[];
criticalIssues: number; criticalIssues: number;
pendingActions: number; pendingActions: number;
aiPreventedIssues: number; // Count of issues AI prevented
} }
export interface ReasoningInputs { export interface ReasoningInputs {
@@ -127,6 +130,53 @@ export interface ActionQueue {
importantCount: 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 { export interface ProductionTimelineItem {
id: string; id: string;
batchNumber: string; batchNumber: string;
@@ -234,7 +284,7 @@ export function useOrchestrationSummary(tenantId: string, runId?: string) {
} }
/** /**
* Get action queue * Get action queue (LEGACY - use useUnifiedActionQueue for new implementation)
* *
* Prioritized list of what requires user attention right now. * Prioritized list of what requires user attention right now.
* This is the core JTBD dashboard feature. * This is the core JTBD dashboard feature.
@@ -254,6 +304,31 @@ export function useActionQueue(tenantId: string) {
}); });
} }
/**
* 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 * Get production timeline
* *
@@ -294,6 +369,31 @@ export function useInsights(tenantId: string) {
}); });
} }
/**
* 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 // Action Mutations
// ============================================================ // ============================================================

View File

@@ -1,665 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/ActionQueueCard.tsx
// ================================================================
/**
* Action Queue Card - What needs your attention right now
*
* Prioritized list of actions the user needs to take, with context
* about why each action is needed and what happens if they don't do it.
*/
import React, { useState, useMemo } from 'react';
import {
FileText,
AlertCircle,
CheckCircle2,
Eye,
Edit,
Clock,
Euro,
ChevronDown,
ChevronUp,
X,
Package,
Building2,
Calendar,
Truck,
} from 'lucide-react';
import { ActionItem, ActionQueue } from '../../api/hooks/newDashboard';
import { useReasoningFormatter } from '../../hooks/useReasoningTranslation';
import { useTranslation } from 'react-i18next';
import { usePurchaseOrder } from '../../api/hooks/purchase-orders';
interface ActionQueueCardProps {
actionQueue: ActionQueue;
loading?: boolean;
onApprove?: (actionId: string) => void;
onReject?: (actionId: string, reason: string) => void;
onViewDetails?: (actionId: string) => void;
onModify?: (actionId: string) => void;
tenantId?: string;
}
const urgencyConfig = {
critical: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
badgeBgColor: 'var(--color-error-100)',
badgeTextColor: 'var(--color-error-800)',
icon: AlertCircle,
iconColor: 'var(--color-error-700)',
},
important: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
badgeBgColor: 'var(--color-warning-100)',
badgeTextColor: 'var(--color-warning-900)',
icon: AlertCircle,
iconColor: 'var(--color-warning-700)',
},
normal: {
bgColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-300)',
badgeBgColor: 'var(--color-info-100)',
badgeTextColor: 'var(--color-info-800)',
icon: FileText,
iconColor: 'var(--color-info-700)',
},
};
/**
* Helper function to translate keys with proper namespace handling
* Maps backend key formats to correct i18next namespaces
*/
function translateKey(
key: string,
params: Record<string, any>,
tDashboard: any,
tReasoning: any
): string {
// Preprocess parameters - join arrays into strings
const processedParams = { ...params };
// Convert product_names array to product_names_joined string
if ('product_names' in processedParams && Array.isArray(processedParams.product_names)) {
processedParams.product_names_joined = processedParams.product_names.join(', ');
}
// Convert affected_products array to affected_products_joined string
if ('affected_products' in processedParams && Array.isArray(processedParams.affected_products)) {
processedParams.affected_products_joined = processedParams.affected_products.join(', ');
}
// Determine namespace based on key prefix
if (key.startsWith('reasoning.')) {
// Remove 'reasoning.' prefix and use reasoning namespace
const translationKey = key.substring('reasoning.'.length);
// Use i18next-icu for interpolation with {variable} syntax
return String(tReasoning(translationKey, processedParams));
} else if (key.startsWith('action_queue.') || key.startsWith('dashboard.') || key.startsWith('health.')) {
// Use dashboard namespace
return String(tDashboard(key, processedParams));
}
// Default to dashboard
return String(tDashboard(key, processedParams));
}
function ActionItemCard({
action,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: {
action: ActionItem;
onApprove?: (id: string) => void;
onReject?: (id: string, reason: string) => void;
onViewDetails?: (id: string) => void;
onModify?: (id: string) => void;
tenantId?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectionReason, setRejectionReason] = useState('');
const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal;
const UrgencyIcon = config.icon;
const { formatPOAction } = useReasoningFormatter();
const { t: tReasoning } = useTranslation('reasoning');
const { t: tDashboard } = useTranslation('dashboard');
// Fetch PO details if this is a PO action and details are expanded
const { data: poDetail } = usePurchaseOrder(
tenantId || '',
action.id,
{ enabled: !!tenantId && showDetails && action.type === 'po_approval' }
);
// Translate i18n fields (or fallback to deprecated text fields or reasoning_data for alerts)
const reasoning = useMemo(() => {
if (action.reasoning_i18n) {
return translateKey(action.reasoning_i18n.key, action.reasoning_i18n.params, tDashboard, tReasoning);
}
if (action.reasoning_data) {
const formatted = formatPOAction(action.reasoning_data);
return formatted.reasoning;
}
return action.reasoning || '';
}, [action.reasoning_i18n, action.reasoning_data, action.reasoning, tDashboard, tReasoning, formatPOAction]);
const consequence = useMemo(() => {
if (action.consequence_i18n) {
return translateKey(action.consequence_i18n.key, action.consequence_i18n.params, tDashboard, tReasoning);
}
if (action.reasoning_data) {
const formatted = formatPOAction(action.reasoning_data);
return formatted.consequence;
}
return '';
}, [action.consequence_i18n, action.reasoning_data, tDashboard, tReasoning, formatPOAction]);
return (
<div
className="border-2 rounded-lg p-4 md:p-5 transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<UrgencyIcon className="w-6 h-6 flex-shrink-0" style={{ color: config.iconColor }} />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
{action.title_i18n ? translateKey(action.title_i18n.key, action.title_i18n.params, tDashboard, tReasoning) : (action.title || 'Action Required')}
</h3>
<span
className="px-2 py-1 rounded text-xs font-semibold uppercase flex-shrink-0"
style={{
backgroundColor: config.badgeBgColor,
color: config.badgeTextColor,
}}
>
{action.urgency || 'normal'}
</span>
</div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{action.subtitle_i18n ? translateKey(action.subtitle_i18n.key, action.subtitle_i18n.params, tDashboard, tReasoning) : (action.subtitle || '')}
</p>
</div>
</div>
{/* Amount (for POs) */}
{action.amount && (
<div className="flex items-center gap-2 mb-3 text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
<Euro className="w-5 h-5" />
<span>
{action.amount.toFixed(2)} {action.currency}
</span>
</div>
)}
{/* Reasoning (always visible) */}
<div className="rounded-md p-3 mb-3" style={{ backgroundColor: 'var(--bg-primary)' }}>
<p className="text-sm font-medium mb-1" style={{ color: 'var(--text-primary)' }}>
{tReasoning('jtbd.action_queue.why_needed')}
</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{reasoning}</p>
</div>
{/* Consequence (expandable) */}
{consequence && (
<>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm transition-colors mb-3 w-full"
style={{ color: 'var(--text-secondary)' }}
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<span className="font-medium">{tReasoning('jtbd.action_queue.what_if_not')}</span>
</button>
{expanded && (
<div
className="border rounded-md p-3 mb-3"
style={{
backgroundColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
}}
>
<p className="text-sm" style={{ color: 'var(--color-warning-900)' }}>{consequence}</p>
</div>
)}
</>
)}
{/* Inline PO Details (expandable) */}
{action.type === 'po_approval' && (
<>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-3 w-full"
style={{ color: 'var(--color-info-700)' }}
>
{showDetails ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Package className="w-4 h-4" />
<span>View Order Details</span>
</button>
{showDetails && poDetail && (
<div
className="border rounded-md p-4 mb-3 space-y-3"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Supplier Info */}
<div className="flex items-start gap-2">
<Building2 className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{poDetail.supplier?.name || 'Supplier'}
</p>
{poDetail.supplier?.contact_person && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Contact: {poDetail.supplier.contact_person}
</p>
)}
{poDetail.supplier?.email && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{poDetail.supplier.email}
</p>
)}
</div>
</div>
{/* Delivery Date & Tracking */}
{poDetail.required_delivery_date && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Required Delivery
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.required_delivery_date).toLocaleDateString()}
</p>
</div>
</div>
{/* Estimated Delivery Date (shown after approval) */}
{poDetail.estimated_delivery_date && (
<div className="flex items-center gap-2 ml-7">
<Truck className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Expected Arrival
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.estimated_delivery_date).toLocaleDateString()}
</p>
</div>
{(() => {
const now = new Date();
const estimatedDate = new Date(poDetail.estimated_delivery_date);
const daysUntil = Math.ceil((estimatedDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let statusColor = 'var(--color-success-600)';
let statusText = 'On Track';
if (daysUntil < 0) {
statusColor = 'var(--color-error-600)';
statusText = `${Math.abs(daysUntil)}d Overdue`;
} else if (daysUntil === 0) {
statusColor = 'var(--color-warning-600)';
statusText = 'Due Today';
} else if (daysUntil <= 2) {
statusColor = 'var(--color-warning-600)';
statusText = `${daysUntil}d Left`;
} else {
statusText = `${daysUntil}d Left`;
}
return (
<span
className="px-2 py-1 rounded text-xs font-semibold"
style={{
backgroundColor: statusColor.replace('600', '100'),
color: statusColor,
}}
>
{statusText}
</span>
);
})()}
</div>
)}
</div>
)}
{/* Line Items */}
{poDetail.items && poDetail.items.length > 0 && (
<div>
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>
Order Items ({poDetail.items.length})
</p>
<div className="space-y-2 max-h-48 overflow-y-auto">
{poDetail.items.map((item, idx) => (
<div
key={idx}
className="flex justify-between items-start p-2 rounded"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{item.product_name || item.product_code || 'Product'}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{item.ordered_quantity} {item.unit_of_measure} × {parseFloat(item.unit_price).toFixed(2)}
</p>
</div>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>
{parseFloat(item.line_total).toFixed(2)}
</p>
</div>
))}
</div>
</div>
)}
{/* Total Amount */}
<div
className="border-t pt-2 flex justify-between items-center"
style={{ borderColor: 'var(--border-primary)' }}
>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>Total Amount</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-info-700)' }}>
{parseFloat(poDetail.total_amount).toFixed(2)}
</p>
</div>
</div>
)}
</>
)}
{/* Time Estimate */}
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
<Clock className="w-4 h-4" />
<span>
{tReasoning('jtbd.action_queue.estimated_time')}: {action.estimatedTimeMinutes || 5} min
</span>
</div>
{/* Rejection Modal */}
{showRejectModal && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={() => setShowRejectModal(false)}
>
<div
className="rounded-lg p-6 max-w-md w-full"
style={{ backgroundColor: 'var(--bg-primary)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
Reject Purchase Order
</h3>
<button
onClick={() => setShowRejectModal(false)}
className="p-1 rounded hover:bg-opacity-10 hover:bg-black"
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Please provide a reason for rejecting this purchase order:
</p>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
placeholder="Enter rejection reason..."
className="w-full p-3 border rounded-lg mb-4 min-h-24"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowRejectModal(false)}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
Cancel
</button>
<button
onClick={() => {
if (onReject && rejectionReason.trim()) {
onReject(action.id, rejectionReason);
setShowRejectModal(false);
setRejectionReason('');
}
}}
disabled={!rejectionReason.trim()}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: rejectionReason.trim() ? 'var(--color-error-600)' : 'var(--bg-quaternary)',
color: rejectionReason.trim() ? 'white' : 'var(--text-tertiary)',
cursor: rejectionReason.trim() ? 'pointer' : 'not-allowed',
}}
>
Reject Order
</button>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{(action.actions || []).map((button, index) => {
const buttonStyles: Record<string, React.CSSProperties> = {
primary: {
backgroundColor: 'var(--color-info-600)',
color: 'var(--text-inverse, #ffffff)',
},
secondary: {
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
},
tertiary: {
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-primary)',
border: '1px solid var(--border-primary)',
},
};
const hoverStyles: Record<string, React.CSSProperties> = {
primary: {
backgroundColor: 'var(--color-info-700)',
},
secondary: {
backgroundColor: 'var(--bg-quaternary)',
},
tertiary: {
backgroundColor: 'var(--bg-secondary)',
},
};
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
if (button.action === 'approve' && onApprove) {
onApprove(action.id);
} else if (button.action === 'reject') {
setShowRejectModal(true);
} else if (button.action === 'view_details' && onViewDetails) {
onViewDetails(action.id);
} else if (button.action === 'modify' && onModify) {
onModify(action.id);
}
};
const currentStyle = {
...buttonStyles[button.type],
...(isHovered ? hoverStyles[button.type] : {}),
};
return (
<button
key={index}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="px-4 py-2 rounded-lg font-semibold text-sm transition-colors duration-200 flex items-center gap-2 min-h-[44px]"
style={currentStyle}
>
{button.action === 'approve' && <CheckCircle2 className="w-4 h-4" />}
{button.action === 'reject' && <X className="w-4 h-4" />}
{button.action === 'view_details' && <Eye className="w-4 h-4" />}
{button.action === 'modify' && <Edit className="w-4 h-4" />}
{translateKey(button.label_i18n.key, button.label_i18n.params, tDashboard, tReasoning)}
</button>
);
})}
</div>
</div>
);
}
export function ActionQueueCard({
actionQueue,
loading,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: ActionQueueCardProps) {
const [showAll, setShowAll] = useState(false);
const { t: tReasoning } = useTranslation('reasoning');
if (loading || !actionQueue) {
return (
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
<div className="animate-pulse space-y-4">
<div className="h-6 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
);
}
if (!actionQueue.actions || actionQueue.actions.length === 0) {
return (
<div
className="border-2 rounded-xl p-8 text-center shadow-lg"
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
>
<CheckCircle2 className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--color-success-600)' }} />
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}>
{tReasoning('jtbd.action_queue.all_caught_up')}
</h3>
<p style={{ color: 'var(--color-success-700)' }}>{tReasoning('jtbd.action_queue.no_actions')}</p>
</div>
);
}
const displayedActions = showAll ? actionQueue.actions : actionQueue.actions.slice(0, 3);
return (
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{tReasoning('jtbd.action_queue.title')}</h2>
{(actionQueue.totalActions || 0) > 3 && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold"
style={{
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-800)',
}}
>
{actionQueue.totalActions || 0} {tReasoning('jtbd.action_queue.total')}
</span>
)}
</div>
{/* Summary Badges */}
{((actionQueue.criticalCount || 0) > 0 || (actionQueue.importantCount || 0) > 0) && (
<div className="flex flex-wrap gap-2 mb-6">
{(actionQueue.criticalCount || 0) > 0 && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold"
style={{
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-800)',
}}
>
{actionQueue.criticalCount || 0} {tReasoning('jtbd.action_queue.critical')}
</span>
)}
{(actionQueue.importantCount || 0) > 0 && (
<span
className="px-3 py-1 rounded-full text-sm font-semibold"
style={{
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-800)',
}}
>
{actionQueue.importantCount || 0} {tReasoning('jtbd.action_queue.important')}
</span>
)}
</div>
)}
{/* Action Items */}
<div className="space-y-4">
{displayedActions.map((action) => (
<ActionItemCard
key={action.id}
action={action}
onApprove={onApprove}
onReject={onReject}
onViewDetails={onViewDetails}
onModify={onModify}
tenantId={tenantId}
/>
))}
</div>
{/* Show More/Less */}
{(actionQueue.totalActions || 0) > 3 && (
<button
onClick={() => setShowAll(!showAll)}
className="w-full mt-4 py-3 rounded-lg font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
{showAll
? tReasoning('jtbd.action_queue.show_less')
: tReasoning('jtbd.action_queue.show_more', { count: (actionQueue.totalActions || 3) - 3 })}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,230 @@
// ================================================================
// frontend/src/components/dashboard/CollapsibleSetupBanner.tsx
// ================================================================
/**
* Collapsible Setup Banner - Recommended Configuration Reminder
*
* JTBD: "Remind me to complete optional setup without blocking my workflow"
*
* This banner appears at the top of the dashboard when:
* - Critical setup is complete (can operate bakery)
* - BUT recommended setup is incomplete (missing features)
* - Progress: 50-99%
*
* Features:
* - Collapsible (default: collapsed to minimize distraction)
* - Dismissible (persists in localStorage for 7 days)
* - Shows progress + remaining sections
* - One-click navigation to incomplete sections
*/
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CheckCircle, ChevronDown, ChevronUp, X, ArrowRight } from 'lucide-react';
interface RemainingSection {
id: string;
title: string;
icon: React.ElementType;
path: string;
count: number;
recommended: number;
}
interface CollapsibleSetupBannerProps {
remainingSections: RemainingSection[];
progressPercentage: number;
onDismiss?: () => void;
}
const DISMISS_KEY = 'setup_banner_dismissed';
const DISMISS_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
export function CollapsibleSetupBanner({ remainingSections, progressPercentage, onDismiss }: CollapsibleSetupBannerProps) {
const { t } = useTranslation(['dashboard']);
const navigate = useNavigate();
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Check if banner was dismissed
useEffect(() => {
const dismissedUntil = localStorage.getItem(DISMISS_KEY);
if (dismissedUntil) {
const dismissedTime = parseInt(dismissedUntil, 10);
if (Date.now() < dismissedTime) {
setDismissed(true);
} else {
localStorage.removeItem(DISMISS_KEY);
}
}
}, []);
const handleDismiss = () => {
const dismissUntil = Date.now() + DISMISS_DURATION;
localStorage.setItem(DISMISS_KEY, dismissUntil.toString());
setDismissed(true);
if (onDismiss) {
onDismiss();
}
};
const handleSectionClick = (path: string) => {
navigate(path);
};
if (dismissed || remainingSections.length === 0) {
return null;
}
return (
<div
className="mb-6 rounded-xl border-2 shadow-md transition-all duration-300"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: expanded ? 'var(--color-info-300)' : 'var(--border-secondary)',
}}
>
{/* Compact Header (Always Visible) */}
<div
className="flex items-center gap-4 p-4 cursor-pointer hover:bg-[var(--bg-secondary)] transition-colors"
onClick={() => setExpanded(!expanded)}
>
{/* Progress Circle */}
<div className="relative w-12 h-12 flex-shrink-0">
<svg className="w-full h-full transform -rotate-90">
{/* Background circle */}
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke="var(--bg-tertiary)"
strokeWidth="4"
/>
{/* Progress circle */}
<circle
cx="24"
cy="24"
r="20"
fill="none"
stroke="var(--color-success)"
strokeWidth="4"
strokeDasharray={`${2 * Math.PI * 20}`}
strokeDashoffset={`${2 * Math.PI * 20 * (1 - progressPercentage / 100)}`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold" style={{ color: 'var(--color-success)' }}>
{progressPercentage}%
</span>
</div>
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-base mb-1" style={{ color: 'var(--text-primary)' }}>
📋 {t('dashboard:setup_banner.title', '{{count}} paso(s) más para desbloquear todas las funciones', { count: remainingSections.length })}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{remainingSections.map(s => s.title).join(', ')} {t('dashboard:setup_banner.recommended', '(recomendado)')}
</p>
</div>
{/* Expand/Collapse Button */}
<button
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
className="flex-shrink-0 p-2 rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
title={expanded ? 'Ocultar' : 'Expandir'}
>
{expanded ? (
<ChevronUp className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
{/* Dismiss Button */}
<button
onClick={(e) => {
e.stopPropagation();
handleDismiss();
}}
className="flex-shrink-0 p-2 rounded-lg hover:bg-[var(--color-error)]/10 transition-colors"
title={t('dashboard:setup_banner.dismiss', 'Ocultar por 7 días')}
>
<X className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
{/* Expanded Content */}
{expanded && (
<div
className="px-4 pb-4 border-t"
style={{ borderColor: 'var(--border-secondary)' }}
>
<div className="space-y-3 mt-4">
{remainingSections.map((section) => {
const Icon = section.icon || (() => <div></div>);
return (
<button
key={section.id}
onClick={() => handleSectionClick(section.path)}
className="w-full flex items-center gap-4 p-4 rounded-lg border border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-all group text-left"
>
{/* Icon */}
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-[var(--bg-tertiary)] group-hover:bg-[var(--color-primary)]/10 transition-colors">
<Icon className="w-5 h-5 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)] transition-colors" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{section.title}
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{section.count} {t('dashboard:setup_banner.added', 'agregado(s)')} {t('dashboard:setup_banner.recommended_count', 'Recomendado')}: {section.recommended}
</p>
</div>
{/* Arrow */}
<ArrowRight className="w-5 h-5 text-[var(--text-tertiary)] group-hover:text-[var(--color-primary)] group-hover:translate-x-1 transition-all" />
</button>
);
})}
</div>
{/* Benefits of Completion */}
<div
className="mt-4 p-4 rounded-lg"
style={{ backgroundColor: 'var(--color-info)]/5', border: '1px solid var(--color-info)/20' }}
>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-info)' }} />
<div>
<h5 className="font-semibold text-sm mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_banner.benefits_title', '✨ Al completar estos pasos, desbloquearás')}:
</h5>
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
<li> {t('dashboard:setup_banner.benefit_1', 'Análisis de costos más preciso')}</li>
<li> {t('dashboard:setup_banner.benefit_2', 'Recomendaciones de IA mejoradas')}</li>
<li> {t('dashboard:setup_banner.benefit_3', 'Planificación de producción optimizada')}</li>
</ul>
</div>
</div>
</div>
{/* Dismiss Info */}
<p className="text-xs text-center mt-4" style={{ color: 'var(--text-tertiary)' }}>
💡 {t('dashboard:setup_banner.dismiss_info', 'Puedes ocultar este banner por 7 días haciendo clic en la X')}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,371 @@
// ================================================================
// frontend/src/components/dashboard/GlanceableHealthHero.tsx
// ================================================================
/**
* Glanceable Health Hero - Simplified Dashboard Status
*
* JTBD-Aligned Design:
* - Core Job: "Quickly understand if anything requires my immediate attention"
* - Emotional Job: "Feel confident to proceed or know to stop and fix"
* - Design Principle: Progressive disclosure (traffic light → details)
*
* States:
* - 🟢 Green: "Everything looks good - proceed with your day"
* - 🟡 Yellow: "Some items need attention - but not urgent"
* - 🔴 Red: "Critical issues - stop and fix these first"
*/
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 { 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';
interface GlanceableHealthHeroProps {
healthStatus: BakeryHealthStatus;
loading?: boolean;
urgentActionCount?: number; // New: show count of urgent actions
}
const statusConfig = {
green: {
bgColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
textColor: 'var(--color-success-900)',
icon: CheckCircle,
iconColor: 'var(--color-success-600)',
iconBg: 'var(--color-success-100)',
},
yellow: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
textColor: 'var(--color-warning-900)',
icon: AlertTriangle,
iconColor: 'var(--color-warning-600)',
iconBg: 'var(--color-warning-100)',
},
red: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
textColor: 'var(--color-error-900)',
icon: AlertCircle,
iconColor: 'var(--color-error-600)',
iconBg: 'var(--color-error-100)',
},
};
const iconMap = {
check: CheckCircle,
warning: AlertTriangle,
alert: AlertCircle,
ai_handled: Zap,
};
/**
* Helper function to translate keys with proper namespace handling
*/
function translateKey(
key: string,
params: Record<string, any>,
t: any
): string {
const namespaceMap: Record<string, string> = {
'health.': 'dashboard',
'dashboard.health.': 'dashboard',
'dashboard.': 'dashboard',
'reasoning.': 'reasoning',
'production.': 'production',
'jtbd.': 'reasoning',
};
let namespace = 'common';
let translationKey = key;
for (const [prefix, ns] of Object.entries(namespaceMap)) {
if (key.startsWith(prefix)) {
namespace = ns;
if (prefix === 'reasoning.') {
translationKey = key.substring(prefix.length);
} else if (prefix === 'dashboard.health.') {
translationKey = key.substring('dashboard.'.length);
} else if (prefix === 'dashboard.' && !key.startsWith('dashboard.health.')) {
translationKey = key.substring('dashboard.'.length);
}
break;
}
}
return t(translationKey, { ...params, ns: namespace, defaultValue: key });
}
export function GlanceableHealthHero({ healthStatus, loading, urgentActionCount = 0 }: GlanceableHealthHeroProps) {
const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']);
const navigate = useNavigate();
const { notifications } = useNotifications();
const [detailsExpanded, setDetailsExpanded] = useState(false);
// Get date-fns locale
const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS;
// ============================================================================
// ALL HOOKS MUST BE CALLED BEFORE ANY EARLY RETURNS
// This ensures hooks are called in the same order on every render
// ============================================================================
// Optimize notifications filtering - cache the filtered array itself
const criticalAlerts = useMemo(() => {
if (!notifications || notifications.length === 0) return [];
return notifications.filter(
n => n.priority_level === 'CRITICAL' && !n.read && n.type_class !== 'prevented_issue'
);
}, [notifications]);
const criticalAlertsCount = criticalAlerts.length;
// Create stable key for checklist items to prevent infinite re-renders
const checklistItemsKey = useMemo(() => {
if (!healthStatus?.checklistItems || healthStatus.checklistItems.length === 0) return 'empty';
return healthStatus.checklistItems.map(item => item.textKey).join(',');
}, [healthStatus?.checklistItems]);
// Update checklist items with real-time data
const updatedChecklistItems = useMemo(() => {
if (!healthStatus?.checklistItems) return [];
return healthStatus.checklistItems.map(item => {
if (item.textKey === 'dashboard.health.critical_issues' && criticalAlertsCount > 0) {
return {
...item,
textParams: { ...item.textParams, count: criticalAlertsCount },
status: 'needs_you' as const,
actionRequired: true,
};
}
return item;
});
}, [checklistItemsKey, criticalAlertsCount]);
// Status and config (use safe defaults for loading state)
const status = healthStatus?.status || 'green';
const config = statusConfig[status];
const StatusIcon = config?.icon || (() => <div>🟢</div>);
// Determine simplified headline for glanceable view (safe for loading state)
const simpleHeadline = useMemo(() => {
if (status === 'green') {
return t('jtbd.health_status.green_simple', { ns: 'reasoning', defaultValue: '✅ Todo listo para hoy' });
} else if (status === 'yellow') {
if (urgentActionCount > 0) {
return t('jtbd.health_status.yellow_simple_with_count', { count: urgentActionCount, ns: 'reasoning', defaultValue: `⚠️ ${urgentActionCount} acción${urgentActionCount > 1 ? 'es' : ''} necesaria${urgentActionCount > 1 ? 's' : ''}` });
}
return t('jtbd.health_status.yellow_simple', { ns: 'reasoning', defaultValue: '⚠️ Algunas cosas necesitan atención' });
} else {
return t('jtbd.health_status.red_simple', { ns: 'reasoning', defaultValue: '🔴 Problemas críticos requieren acción' });
}
}, [status, urgentActionCount, t]);
const displayCriticalIssues = criticalAlertsCount > 0 ? criticalAlertsCount : (healthStatus?.criticalIssues || 0);
// ============================================================================
// NOW it's safe to early return - all hooks have been called
// ============================================================================
if (loading || !healthStatus) {
return (
<div className="animate-pulse rounded-xl shadow-lg p-6 border-2 border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="h-16 rounded w-2/3 mb-4 bg-[var(--bg-tertiary)]"></div>
<div className="h-6 rounded w-1/2 bg-[var(--bg-tertiary)]"></div>
</div>
);
}
return (
<div
className="border-2 rounded-xl shadow-xl transition-all duration-300 hover:shadow-2xl"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Glanceable Hero View (Always Visible) */}
<div className="p-6">
<div className="flex items-center gap-4">
{/* Status Icon */}
<div
className="flex-shrink-0 w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center shadow-md"
style={{ backgroundColor: config.iconBg }}
>
<StatusIcon className="w-8 h-8 md:w-10 md:h-10" strokeWidth={2.5} style={{ color: config.iconColor }} />
</div>
{/* Headline + Quick Stats */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: config.textColor }}>
{simpleHeadline}
</h2>
{/* Quick Stats Row */}
<div className="flex flex-wrap items-center gap-3 text-sm">
{/* Last Update */}
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{healthStatus.lastOrchestrationRun
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
addSuffix: true,
locale: dateLocale,
})
: t('jtbd.health_status.never', { ns: 'reasoning' })}
</span>
</div>
{/* Critical Issues Badge */}
{displayCriticalIssues > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-error-100)', color: 'var(--color-error-800)' }}>
<AlertCircle className={`w-4 h-4 ${criticalAlertsCount > 0 ? 'animate-pulse' : ''}`} />
<span className="font-semibold">{displayCriticalIssues} crítico{displayCriticalIssues > 1 ? 's' : ''}</span>
</div>
)}
{/* Pending Actions Badge */}
{healthStatus.pendingActions > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-warning-100)', color: 'var(--color-warning-800)' }}>
<AlertTriangle className="w-4 h-4" />
<span className="font-semibold">{healthStatus.pendingActions} pendiente{healthStatus.pendingActions > 1 ? 's' : ''}</span>
</div>
)}
{/* AI Prevented Badge */}
{healthStatus.aiPreventedIssues && healthStatus.aiPreventedIssues > 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>
</div>
)}
</div>
</div>
{/* Expand/Collapse Button */}
<button
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="flex-shrink-0 p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]"
title={detailsExpanded ? 'Ocultar detalles' : 'Ver detalles'}
>
{detailsExpanded ? (
<ChevronUp className="w-6 h-6" style={{ color: config.textColor }} />
) : (
<ChevronDown className="w-6 h-6" style={{ color: config.textColor }} />
)}
</button>
</div>
</div>
{/* Detailed Checklist (Collapsible) */}
{detailsExpanded && (
<div
className="px-6 pb-6 pt-2 border-t border-[var(--border-primary)]"
style={{ borderColor: config.borderColor }}
>
{/* Full Headline */}
<p className="text-base mb-4 text-[var(--text-secondary)]">
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t)
: healthStatus.headline}
</p>
{/* Checklist */}
{updatedChecklistItems && updatedChecklistItems.length > 0 && (
<div className="space-y-2">
{updatedChecklistItems.map((item, index) => {
// Safely get the icon with proper validation
const SafeIconComponent = iconMap[item.icon];
const ItemIcon = SafeIconComponent || AlertCircle;
const getStatusStyles = () => {
switch (item.status) {
case 'good':
return {
iconColor: 'var(--color-success-600)',
bgColor: 'var(--color-success-50)',
borderColor: 'transparent',
};
case 'ai_handled':
return {
iconColor: 'var(--color-info-600)',
bgColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-300)',
};
case 'needs_you':
return {
iconColor: 'var(--color-warning-600)',
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
};
default:
return {
iconColor: item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)',
bgColor: item.actionRequired ? 'var(--bg-primary)' : 'var(--bg-secondary)',
borderColor: 'transparent',
};
}
};
const styles = getStatusStyles();
const displayText = item.textKey
? translateKey(item.textKey, item.textParams || {}, t)
: item.text || '';
const handleClick = () => {
if (item.actionPath && (item.status === 'needs_you' || item.actionRequired)) {
navigate(item.actionPath);
}
};
const isClickable = Boolean(item.actionPath && (item.status === 'needs_you' || item.actionRequired));
return (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border transition-all ${
isClickable ? 'cursor-pointer hover:shadow-md hover:scale-[1.01] bg-[var(--bg-tertiary)]' : ''
}`}
style={{
backgroundColor: styles.bgColor,
borderColor: styles.borderColor,
}}
onClick={handleClick}
>
<ItemIcon className="w-5 h-5 flex-shrink-0" style={{ color: styles.iconColor }} />
<span
className={`flex-1 text-sm ${
item.status === 'needs_you' || item.actionRequired ? 'font-semibold' : ''
} text-[var(--text-primary)]`}
>
{displayText}
</span>
{isClickable && (
<ChevronRight className="w-4 h-4 flex-shrink-0 text-[var(--text-tertiary)]" />
)}
</div>
);
})}
</div>
)}
{/* Next Check */}
{healthStatus.nextScheduledRun && (
<div className="flex items-center gap-2 text-sm mt-4 p-3 rounded-lg bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
<RefreshCw className="w-4 h-4" />
<span>
{t('jtbd.health_status.next_check', { ns: 'reasoning' })}:{' '}
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,221 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/HealthStatusCard.tsx
// ================================================================
/**
* Health Status Card - Top-level bakery status indicator
*
* Shows if the bakery is running smoothly (green), needs attention (yellow),
* or has critical issues (red). This is the first thing users see.
*/
import React from 'react';
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw } from 'lucide-react';
import { BakeryHealthStatus } from '../../api/hooks/newDashboard';
import { formatDistanceToNow } from 'date-fns';
import { es, eu, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
interface HealthStatusCardProps {
healthStatus: BakeryHealthStatus;
loading?: boolean;
}
const statusConfig = {
green: {
bgColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
textColor: 'var(--color-success-800)',
icon: CheckCircle,
iconColor: 'var(--color-success-600)',
},
yellow: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
textColor: 'var(--color-warning-900)',
icon: AlertTriangle,
iconColor: 'var(--color-warning-600)',
},
red: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
textColor: 'var(--color-error-900)',
icon: AlertCircle,
iconColor: 'var(--color-error-600)',
},
};
const iconMap = {
check: CheckCircle,
warning: AlertTriangle,
alert: AlertCircle,
};
/**
* Helper function to translate keys with proper namespace handling
* Maps backend key formats to correct i18next namespaces
*/
function translateKey(
key: string,
params: Record<string, any>,
t: any
): string {
// Map key prefixes to their correct namespaces
const namespaceMap: Record<string, string> = {
'health.': 'dashboard',
'dashboard.health.': 'dashboard',
'dashboard.': 'dashboard',
'reasoning.': 'reasoning',
'production.': 'production',
'jtbd.': 'reasoning',
};
// Find the matching namespace
let namespace = 'common';
let translationKey = key;
for (const [prefix, ns] of Object.entries(namespaceMap)) {
if (key.startsWith(prefix)) {
namespace = ns;
// Remove the first segment if it matches the namespace
// e.g., "dashboard.health.production_on_schedule" -> "health.production_on_schedule"
// e.g., "reasoning.types.xyz" -> "types.xyz" for reasoning namespace
if (prefix === 'reasoning.') {
translationKey = key.substring(prefix.length);
} else if (prefix === 'dashboard.health.') {
// Keep "health." prefix but remove "dashboard."
translationKey = key.substring('dashboard.'.length);
} else if (prefix === 'dashboard.' && !key.startsWith('dashboard.health.')) {
// For other dashboard keys, remove "dashboard." prefix
translationKey = key.substring('dashboard.'.length);
}
break;
}
}
return t(translationKey, { ...params, ns: namespace, defaultValue: key });
}
export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProps) {
const { t, i18n } = useTranslation(['dashboard', 'reasoning', 'production']);
// Get date-fns locale based on current language
const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS;
if (loading || !healthStatus) {
return (
<div className="animate-pulse rounded-lg shadow-md p-6" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="h-8 rounded w-3/4 mb-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
);
}
const status = healthStatus.status || 'green';
const config = statusConfig[status];
const StatusIcon = config.icon;
return (
<div
className="border-2 rounded-xl p-6 shadow-lg transition-all duration-300 hover:shadow-xl"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Header with Status Icon */}
<div className="flex items-start gap-4 mb-4">
<div className="flex-shrink-0">
<StatusIcon className="w-10 h-10 md:w-12 md:h-12" strokeWidth={2} style={{ color: config.iconColor }} />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl md:text-2xl font-bold mb-2" style={{ color: config.textColor }}>
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t)
: healthStatus.headline || t(`jtbd.health_status.${status}`, { ns: 'reasoning' })}
</h2>
{/* Last Update */}
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('jtbd.health_status.last_updated', { ns: 'reasoning' })}:{' '}
{healthStatus.lastOrchestrationRun
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
addSuffix: true,
locale: dateLocale,
})
: t('jtbd.health_status.never', { ns: 'reasoning' })}
</span>
</div>
{/* Next Check */}
{healthStatus.nextScheduledRun && (
<div className="flex items-center gap-2 text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
<RefreshCw className="w-4 h-4" />
<span>
{t('jtbd.health_status.next_check', { ns: 'reasoning' })}:{' '}
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
</span>
</div>
)}
</div>
</div>
{/* Status Checklist */}
{healthStatus.checklistItems && healthStatus.checklistItems.length > 0 && (
<div className="space-y-3 mt-6">
{healthStatus.checklistItems.map((item, index) => {
const ItemIcon = iconMap[item.icon];
const iconColor = item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)';
const bgColor = item.actionRequired ? 'var(--bg-primary)' : 'rgba(255, 255, 255, 0.5)';
// Translate using textKey if available, otherwise use text
const displayText = item.textKey
? translateKey(item.textKey, item.textParams || {}, t)
: item.text || '';
return (
<div
key={index}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ backgroundColor: bgColor }}
>
<ItemIcon className="w-5 h-5 flex-shrink-0" style={{ color: iconColor }} />
<span
className={`text-sm md:text-base ${item.actionRequired ? 'font-semibold' : ''}`}
style={{ color: 'var(--text-primary)' }}
>
{displayText}
</span>
</div>
);
})}
</div>
)}
{/* Summary Footer */}
{(healthStatus.criticalIssues > 0 || healthStatus.pendingActions > 0) && (
<div className="mt-6 pt-4" style={{ borderTop: '1px solid var(--border-primary)' }}>
<div className="flex flex-wrap gap-4 text-sm">
{healthStatus.criticalIssues > 0 && (
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
<span className="font-semibold" style={{ color: 'var(--color-error-800)' }}>
{t('jtbd.health_status.critical_issues', { count: healthStatus.criticalIssues, ns: 'reasoning' })}
</span>
</div>
)}
{healthStatus.pendingActions > 0 && (
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" style={{ color: 'var(--color-warning-600)' }} />
<span className="font-semibold" style={{ color: 'var(--color-warning-800)' }}>
{t('jtbd.health_status.actions_needed', { count: healthStatus.pendingActions, ns: 'reasoning' })}
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,129 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/InsightsGrid.tsx
// ================================================================
/**
* Insights Grid - Key metrics at a glance
*
* 2x2 grid of important metrics: savings, inventory, waste, deliveries.
* Mobile-first design with large touch targets.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Insights } from '../../api/hooks/newDashboard';
interface InsightsGridProps {
insights: Insights;
loading?: boolean;
}
const colorConfig = {
green: {
bgColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
textColor: 'var(--color-success-800)',
detailColor: 'var(--color-success-600)',
},
amber: {
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-200)',
textColor: 'var(--color-warning-900)',
detailColor: 'var(--color-warning-600)',
},
red: {
bgColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
textColor: 'var(--color-error-900)',
detailColor: 'var(--color-error-600)',
},
};
function InsightCard({
color,
i18n,
}: {
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;
};
}) {
const { t } = useTranslation('dashboard');
const config = colorConfig[color];
// Translate using i18n keys
const displayLabel = t(i18n.label.key, i18n.label.params);
const displayValue = t(i18n.value.key, i18n.value.params);
const displayDetail = i18n.detail ? t(i18n.detail.key, i18n.detail.params) : '';
return (
<div
className="border-2 rounded-xl p-4 md:p-6 transition-all duration-200 hover:shadow-lg cursor-pointer"
style={{
backgroundColor: config.bgColor,
borderColor: config.borderColor,
}}
>
{/* Label */}
<div className="text-sm md:text-base font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{displayLabel}</div>
{/* Value */}
<div className="text-xl md:text-2xl font-bold mb-1" style={{ color: config.textColor }}>{displayValue}</div>
{/* Detail */}
<div className="text-sm font-medium" style={{ color: config.detailColor }}>{displayDetail}</div>
</div>
);
}
export function InsightsGrid({ insights, loading }: InsightsGridProps) {
if (loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="animate-pulse rounded-xl p-6" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="h-4 rounded w-1/2 mb-3" style={{ backgroundColor: 'var(--bg-quaternary)' }}></div>
<div className="h-8 rounded w-3/4 mb-2" style={{ backgroundColor: 'var(--bg-quaternary)' }}></div>
<div className="h-4 rounded w-2/3" style={{ backgroundColor: 'var(--bg-quaternary)' }}></div>
</div>
))}
</div>
);
}
// Guard against undefined values
if (!insights || !insights.savings || !insights.inventory || !insights.waste || !insights.deliveries) {
return null;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InsightCard
color={insights.savings.color}
i18n={insights.savings.i18n}
/>
<InsightCard
color={insights.inventory.color}
i18n={insights.inventory.i18n}
/>
<InsightCard
color={insights.waste.color}
i18n={insights.waste.i18n}
/>
<InsightCard
color={insights.deliveries.color}
i18n={insights.deliveries.i18n}
/>
</div>
);
}

View File

@@ -0,0 +1,432 @@
// ================================================================
// frontend/src/components/dashboard/IntelligentSystemSummaryCard.tsx
// ================================================================
/**
* Intelligent System Summary Card - Unified AI Impact Component
*
* Simplified design matching GlanceableHealthHero pattern:
* - Clean, scannable header with inline metrics badges
* - Minimal orchestration summary (details shown elsewhere)
* - Progressive disclosure for prevented issues details
*/
import React, { useState, useEffect, useMemo } from 'react';
import {
Bot,
TrendingUp,
TrendingDown,
Clock,
CheckCircle,
ChevronDown,
ChevronUp,
Zap,
ShieldCheck,
Euro,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/newDashboard';
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 { Badge } from '../ui/Badge';
interface PeriodComparison {
current_period: {
days: number;
total_alerts: number;
prevented_issues: number;
handling_rate_percentage: number;
};
previous_period: {
days: number;
total_alerts: number;
prevented_issues: number;
handling_rate_percentage: number;
};
changes: {
handling_rate_change_percentage: number;
alert_count_change_percentage: number;
trend_direction: 'up' | 'down' | 'stable';
};
}
interface DashboardAnalytics {
period_days: number;
total_alerts: number;
active_alerts: number;
ai_handling_rate: number;
prevented_issues_count: number;
estimated_savings_eur: number;
total_financial_impact_at_risk_eur: number;
period_comparison?: PeriodComparison;
}
interface IntelligentSystemSummaryCardProps {
orchestrationSummary: OrchestrationSummary;
orchestrationLoading?: boolean;
onWorkflowComplete?: () => void;
className?: string;
}
export function IntelligentSystemSummaryCard({
orchestrationSummary,
orchestrationLoading,
onWorkflowComplete,
className = '',
}: IntelligentSystemSummaryCardProps) {
const { t } = useTranslation(['dashboard', 'reasoning']);
const { currentTenant } = useTenant();
const { notifications } = useNotifications();
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [preventedAlerts, setPreventedAlerts] = useState<EnrichedAlert[]>([]);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
const [preventedIssuesExpanded, setPreventedIssuesExpanded] = useState(false);
const [orchestrationExpanded, setOrchestrationExpanded] = useState(false);
// Fetch analytics data
useEffect(() => {
const fetchAnalytics = async () => {
if (!currentTenant?.id) {
setAnalyticsLoading(false);
return;
}
try {
setAnalyticsLoading(true);
const { apiClient } = await import('../../api/client/apiClient');
const [analyticsData, alertsData] = await Promise.all([
apiClient.get<DashboardAnalytics>(
`/tenants/${currentTenant.id}/alerts/analytics/dashboard`,
{ params: { days: 30 } }
),
apiClient.get<{ alerts: EnrichedAlert[] }>(
`/tenants/${currentTenant.id}/alerts`,
{ params: { limit: 100 } }
),
]);
setAnalytics(analyticsData);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const filteredAlerts = (alertsData.alerts || [])
.filter(
(alert) =>
alert.type_class === 'prevented_issue' &&
new Date(alert.created_at) >= sevenDaysAgo
)
.slice(0, 20);
setPreventedAlerts(filteredAlerts);
} catch (err) {
console.error('Error fetching intelligent system data:', err);
} finally {
setAnalyticsLoading(false);
}
};
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]);
// Calculate metrics
const totalSavings = analytics?.estimated_savings_eur || 0;
const trendPercentage = analytics?.period_comparison?.changes?.handling_rate_change_percentage || 0;
const hasPositiveTrend = trendPercentage > 0;
// Loading state
if (analyticsLoading || orchestrationLoading) {
return (
<div
className={`rounded-xl shadow-xl p-6 border-2 ${className}`}
style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}
>
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="flex-1">
<div className="h-6 rounded w-1/2 mb-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded w-3/4" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
</div>
</div>
);
}
return (
<div
className={`rounded-xl shadow-xl border-2 transition-all duration-300 hover:shadow-2xl ${className}`}
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
>
{/* Always Visible Header - GlanceableHealthHero Style */}
<div className="p-6">
<div className="flex items-center gap-4">
{/* Icon */}
<div
className="w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center flex-shrink-0 shadow-md"
style={{ backgroundColor: 'var(--color-success-100)' }}
>
<Bot className="w-8 h-8 md:w-10 md:h-10" style={{ color: 'var(--color-success-600)' }} />
</div>
{/* Title + Metrics Badges */}
<div className="flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:intelligent_system.title', 'Intelligent System Summary')}
</h2>
{/* Inline Metrics Badges */}
<div className="flex flex-wrap items-center gap-3">
{/* AI Handling Rate Badge */}
<div
className="flex items-center gap-2 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
>
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{analytics?.ai_handling_rate.toFixed(1)}%
</span>
{hasPositiveTrend ? (
<TrendingUp className="w-3 h-3" style={{ color: 'var(--color-success-600)' }} />
) : (
<TrendingDown className="w-3 h-3" style={{ color: 'var(--color-error)' }} />
)}
{trendPercentage !== 0 && (
<span className="text-xs" style={{ color: 'var(--color-success-600)' }}>
{trendPercentage > 0 ? '+' : ''}{trendPercentage}%
</span>
)}
</div>
{/* Prevented Issues Badge */}
<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}
</span>
<span className="text-xs" style={{ color: 'var(--color-primary-600)' }}>
{t('dashboard:intelligent_system.prevented_issues', 'issues')}
</span>
</div>
{/* Savings Badge */}
<div
className="flex items-center gap-1 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
>
<Euro className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{totalSavings.toFixed(0)}
</span>
<span className="text-xs" style={{ color: 'var(--color-success-600)' }}>
saved
</span>
</div>
</div>
</div>
{/* Expand Button */}
<button
onClick={() => setPreventedIssuesExpanded(!preventedIssuesExpanded)}
className="flex-shrink-0 p-2 rounded-lg hover:bg-black/5 transition-colors"
>
{preventedIssuesExpanded ? (
<ChevronUp className="w-6 h-6" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-6 h-6" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
</div>
</div>
{/* 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 ? (
<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!')}
</p>
</div>
) : (
<>
{/* Celebration Message */}
<div
className="rounded-lg p-3 mb-4"
style={{ backgroundColor: 'var(--color-success-100)', border: '1px solid var(--color-success-300)' }}
>
<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,
})}
</p>
</div>
{/* Prevented Issues List */}
<div className="space-y-2">
{preventedAlerts.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';
return (
<div
key={alert.id}
className="rounded-lg p-3 border"
style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-start gap-2 flex-1">
<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}
</h4>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{alert.message}
</p>
</div>
</div>
{savings > 0 && (
<Badge variant="success" className="ml-2 flex-shrink-0">
<Euro className="w-3 h-3 mr-1" />
{savings.toFixed(0)}
</Badge>
)}
</div>
<div className="flex items-center justify-between text-xs" style={{ color: 'var(--text-tertiary)' }}>
<div className="flex items-center gap-1">
<Zap className="w-3 h-3" />
<span>{actionTaken}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{timeAgo}</span>
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
)}
{/* Collapsible Section: Latest Orchestration Run (Ultra Minimal) */}
<div className="border-t" style={{ borderColor: 'var(--color-success-200)' }}>
<button
onClick={() => setOrchestrationExpanded(!orchestrationExpanded)}
className="w-full flex items-center justify-between px-6 py-3 hover:bg-black/5 transition-colors"
>
<div className="flex items-center gap-2">
<Bot className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
<h3 className="text-base font-bold" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:intelligent_system.orchestration_title', 'Latest Orchestration Run')}
</h3>
</div>
{orchestrationExpanded ? (
<ChevronUp className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
{orchestrationExpanded && (
<div className="px-6 pb-4">
{orchestrationSummary && orchestrationSummary.status !== 'no_runs' ? (
<div className="space-y-2">
{/* Run Info Line */}
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('reasoning:jtbd.orchestration_summary.run_info', 'Run #{runNumber}', {
runNumber: orchestrationSummary.runNumber || 0,
})}{' '}
{' '}
{orchestrationSummary.runTimestamp
? formatRelativeTime(orchestrationSummary.runTimestamp) || 'recently'
: 'recently'}
{orchestrationSummary.durationSeconds && `${orchestrationSummary.durationSeconds}s`}
</span>
</div>
{/* Summary Line */}
<div className="text-sm" style={{ color: 'var(--text-primary)' }}>
{orchestrationSummary.purchaseOrdersCreated > 0 && (
<span>
<span className="font-semibold">{orchestrationSummary.purchaseOrdersCreated}</span>{' '}
{orchestrationSummary.purchaseOrdersCreated === 1 ? 'purchase order' : 'purchase orders'}
{orchestrationSummary.purchaseOrdersSummary && orchestrationSummary.purchaseOrdersSummary.length > 0 && (
<span>
{' '}(
{orchestrationSummary.purchaseOrdersSummary
.reduce((sum, po) => sum + (po.totalAmount || 0), 0)
.toFixed(0)}
)
</span>
)}
</span>
)}
{orchestrationSummary.purchaseOrdersCreated > 0 && orchestrationSummary.productionBatchesCreated > 0 && ' • '}
{orchestrationSummary.productionBatchesCreated > 0 && (
<span>
<span className="font-semibold">{orchestrationSummary.productionBatchesCreated}</span>{' '}
{orchestrationSummary.productionBatchesCreated === 1 ? 'production batch' : 'production batches'}
{orchestrationSummary.productionBatchesSummary && orchestrationSummary.productionBatchesSummary.length > 0 && (
<span>
{' '}(
{orchestrationSummary.productionBatchesSummary[0].readyByTime
? formatTime(orchestrationSummary.productionBatchesSummary[0].readyByTime, 'HH:mm')
: 'TBD'}
{orchestrationSummary.productionBatchesSummary.length > 1 &&
orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1]
.readyByTime &&
` - ${formatTime(
orchestrationSummary.productionBatchesSummary[orchestrationSummary.productionBatchesSummary.length - 1]
.readyByTime,
'HH:mm'
)}`}
)
</span>
)}
</span>
)}
{orchestrationSummary.purchaseOrdersCreated === 0 && orchestrationSummary.productionBatchesCreated === 0 && (
<span style={{ color: 'var(--text-secondary)' }}>
{t('reasoning:jtbd.orchestration_summary.no_actions', 'No actions created')}
</span>
)}
</div>
</div>
) : (
<div className="text-sm text-center py-4" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:orchestration.no_runs_message', 'No orchestration has been run yet.')}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,316 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/OrchestrationSummaryCard.tsx
// ================================================================
/**
* Orchestration Summary Card - What the system did for you
*
* Builds trust by showing transparency into automation decisions.
* Narrative format makes it feel like a helpful assistant.
*/
import React, { useState } from 'react';
import {
Bot,
TrendingUp,
Package,
Clock,
CheckCircle,
FileText,
Users,
Brain,
ChevronDown,
ChevronUp,
Loader2,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/newDashboard';
import { runDailyWorkflow } from '../../api/services/orchestrator';
import { formatDistanceToNow } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useTenant } from '../../stores/tenant.store';
import toast from 'react-hot-toast';
interface OrchestrationSummaryCardProps {
summary: OrchestrationSummary;
loading?: boolean;
onWorkflowComplete?: () => void;
}
export function OrchestrationSummaryCard({ summary, loading, onWorkflowComplete }: OrchestrationSummaryCardProps) {
const [expanded, setExpanded] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const { t } = useTranslation('reasoning');
const { currentTenant } = useTenant();
const handleRunPlanning = async () => {
if (!currentTenant?.id) {
toast.error(t('jtbd.orchestration_summary.no_tenant_error') || 'No tenant ID found');
return;
}
setIsRunning(true);
try {
const result = await runDailyWorkflow(currentTenant.id);
if (result.success) {
toast.success(t('jtbd.orchestration_summary.planning_started') || 'Planning started successfully');
// Call callback to refresh the orchestration summary
if (onWorkflowComplete) {
onWorkflowComplete();
}
} else {
toast.error(result.message || t('jtbd.orchestration_summary.planning_failed') || 'Failed to start planning');
}
} catch (error) {
console.error('Error running daily workflow:', error);
toast.error(t('jtbd.orchestration_summary.planning_error') || 'An error occurred while starting planning');
} finally {
setIsRunning(false);
}
};
if (loading || !summary) {
return (
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-6 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
<div className="space-y-2">
<div className="h-4 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-4 rounded w-5/6" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
</div>
);
}
// Handle case where no orchestration has run yet
if (summary.status === 'no_runs') {
return (
<div
className="border-2 rounded-xl p-6 shadow-lg"
style={{
backgroundColor: 'var(--surface-secondary)',
borderColor: 'var(--color-info-300)',
}}
>
<div className="flex items-start gap-4">
<Bot className="w-10 h-10 flex-shrink-0" style={{ color: 'var(--color-info)' }} />
<div>
<h3 className="text-lg font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.ready_to_plan')}
</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>{summary.message || ''}</p>
<button
onClick={handleRunPlanning}
disabled={isRunning}
className="px-4 py-2 rounded-lg font-semibold transition-colors duration-200 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90"
style={{
backgroundColor: 'var(--color-info)',
color: 'var(--bg-primary)',
}}
>
{isRunning && <Loader2 className="w-4 h-4 animate-spin" />}
{t('jtbd.orchestration_summary.run_planning')}
</button>
</div>
</div>
</div>
);
}
const runTime = summary.runTimestamp
? formatDistanceToNow(new Date(summary.runTimestamp), { addSuffix: true })
: 'recently';
return (
<div
className="rounded-xl shadow-lg p-6 border"
style={{
background: 'linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%)',
borderColor: 'var(--border-primary)',
}}
>
{/* Header */}
<div className="flex items-start gap-4 mb-6">
<div className="p-3 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<Bot className="w-8 h-8" style={{ color: 'var(--color-primary)' }} />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.title')}
</h2>
<div className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('jtbd.orchestration_summary.run_info', { runNumber: summary.runNumber || 0 })} {runTime}
</span>
{summary.durationSeconds && (
<span style={{ color: 'var(--text-tertiary)' }}>
{t('jtbd.orchestration_summary.took', { seconds: summary.durationSeconds })}
</span>
)}
</div>
</div>
</div>
{/* Purchase Orders Created */}
{summary.purchaseOrdersCreated > 0 && (
<div className="rounded-lg p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.created_pos', { count: summary.purchaseOrdersCreated })}
</h3>
</div>
{summary.purchaseOrdersSummary && summary.purchaseOrdersSummary.length > 0 && (
<ul className="space-y-2 ml-8">
{summary.purchaseOrdersSummary.map((po, index) => (
<li key={index} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium">{po.supplierName || 'Unknown Supplier'}</span>
{' • '}
{(po.itemCategories || []).slice(0, 2).join(', ') || 'Items'}
{(po.itemCategories || []).length > 2 && ` +${po.itemCategories.length - 2} more`}
{' • '}
<span className="font-semibold">{(po.totalAmount || 0).toFixed(2)}</span>
</li>
))}
</ul>
)}
</div>
)}
{/* Production Batches Created */}
{summary.productionBatchesCreated > 0 && (
<div className="rounded-lg p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-5 h-5" style={{ color: 'var(--color-success-600)' }} />
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.scheduled_batches', {
count: summary.productionBatchesCreated,
})}
</h3>
</div>
{summary.productionBatchesSummary && summary.productionBatchesSummary.length > 0 && (
<ul className="space-y-2 ml-8">
{summary.productionBatchesSummary.slice(0, expanded ? undefined : 3).map((batch, index) => (
<li key={index} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-semibold">{batch.quantity || 0}</span> {batch.productName || 'Product'}
{' • '}
<span style={{ color: 'var(--text-tertiary)' }}>
ready by {batch.readyByTime ? new Date(batch.readyByTime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
}) : 'TBD'}
</span>
</li>
))}
</ul>
)}
{summary.productionBatchesSummary && summary.productionBatchesSummary.length > 3 && (
<button
onClick={() => setExpanded(!expanded)}
className="ml-8 mt-2 flex items-center gap-1 text-sm font-medium"
style={{ color: 'var(--color-primary-600)' }}
>
{expanded ? (
<>
<ChevronUp className="w-4 h-4" />
{t('jtbd.orchestration_summary.show_less')}
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
{t('jtbd.orchestration_summary.show_more', {
count: summary.productionBatchesSummary.length - 3,
})}
</>
)}
</button>
)}
</div>
)}
{/* No actions created */}
{summary.purchaseOrdersCreated === 0 && summary.productionBatchesCreated === 0 && (
<div className="rounded-lg p-4 mb-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-secondary)' }}>{t('jtbd.orchestration_summary.no_actions')}</p>
</div>
</div>
)}
{/* Reasoning Inputs (How decisions were made) */}
<div className="rounded-lg p-4" style={{ backgroundColor: 'var(--surface-secondary)', border: '1px solid var(--border-primary)' }}>
<div className="flex items-center gap-2 mb-3">
<Brain className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
<h3 className="font-bold" style={{ color: 'var(--text-primary)' }}>{t('jtbd.orchestration_summary.based_on')}</h3>
</div>
<div className="grid grid-cols-2 gap-3 ml-7">
{summary.reasoningInputs.customerOrders > 0 && (
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>
{t('jtbd.orchestration_summary.customer_orders', {
count: summary.reasoningInputs.customerOrders,
})}
</span>
</div>
)}
{summary.reasoningInputs.historicalDemand && (
<div className="flex items-center gap-2 text-sm">
<TrendingUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>
{t('jtbd.orchestration_summary.historical_demand')}
</span>
</div>
)}
{summary.reasoningInputs.inventoryLevels && (
<div className="flex items-center gap-2 text-sm">
<Package className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>{t('jtbd.orchestration_summary.inventory_levels')}</span>
</div>
)}
{summary.reasoningInputs.aiInsights && (
<div className="flex items-center gap-2 text-sm">
<Brain className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
<span className="font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('jtbd.orchestration_summary.ai_optimization')}
</span>
</div>
)}
</div>
</div>
{/* Actions Required Footer */}
{summary.userActionsRequired > 0 && (
<div
className="mt-4 p-4 border rounded-lg"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--color-warning)',
}}
>
<div className="flex items-center gap-2">
<FileText className="w-5 h-5" style={{ color: 'var(--color-warning)' }} />
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.orchestration_summary.actions_required', {
count: summary.userActionsRequired,
})}
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,324 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/ProductionTimelineCard.tsx
// ================================================================
/**
* Production Timeline Card - Today's production schedule
*
* Chronological view of what's being made today with real-time progress.
*/
import React, { useMemo } from 'react';
import { Factory, Clock, Play, Pause, CheckCircle2 } from 'lucide-react';
import { ProductionTimeline, ProductionTimelineItem } from '../../api/hooks/newDashboard';
import { useReasoningFormatter } from '../../hooks/useReasoningTranslation';
import { useTranslation } from 'react-i18next';
interface ProductionTimelineCardProps {
timeline: ProductionTimeline;
loading?: boolean;
onStart?: (batchId: string) => void;
onPause?: (batchId: string) => void;
}
const priorityColors = {
URGENT: 'var(--color-error-600)',
HIGH: 'var(--color-warning-600)',
MEDIUM: 'var(--color-info-600)',
LOW: 'var(--text-tertiary)',
};
function TimelineItemCard({
item,
onStart,
onPause,
}: {
item: ProductionTimelineItem;
onStart?: (id: string) => void;
onPause?: (id: string) => void;
}) {
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || 'var(--text-tertiary)';
const { formatBatchAction } = useReasoningFormatter();
const { t } = useTranslation(['reasoning', 'dashboard', 'production']);
// Translate reasoning_data (or use new reasoning_i18n or fallback to deprecated text field)
// Memoize to prevent undefined values from being created on each render
const { reasoning } = useMemo(() => {
if (item.reasoning_i18n) {
// Use new i18n structure if available
const { key, params = {} } = item.reasoning_i18n;
// Handle namespace - remove "reasoning." prefix for reasoning namespace
let translationKey = key;
let namespace = 'reasoning';
if (key.startsWith('reasoning.')) {
translationKey = key.substring('reasoning.'.length);
} else if (key.startsWith('production.')) {
translationKey = key.substring('production.'.length);
namespace = 'production';
}
// Use i18next-icu for interpolation with {variable} syntax
const fullKey = `${namespace}:${translationKey}`;
const result = t(fullKey, params);
return { reasoning: String(result || item.reasoning || '') };
} else if (item.reasoning_data) {
return formatBatchAction(item.reasoning_data);
}
return { reasoning: item.reasoning || '' };
}, [item.reasoning_i18n, item.reasoning_data, item.reasoning, formatBatchAction, t]);
const startTime = item.plannedStartTime
? new Date(item.plannedStartTime).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'N/A';
const readyByTime = item.readyBy
? new Date(item.readyBy).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'N/A';
return (
<div
className="flex gap-4 p-4 md:p-5 rounded-lg border-2 hover:shadow-md transition-all duration-200"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Timeline icon and connector */}
<div className="flex flex-col items-center flex-shrink-0">
<div className="text-2xl">{item.statusIcon || '🔵'}</div>
<div className="text-xs font-mono mt-1" style={{ color: 'var(--text-tertiary)' }}>{startTime}</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>{item.productName || 'Product'}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{item.quantity || 0} {item.unit || 'units'} Batch #{item.batchNumber || 'N/A'}
</p>
</div>
<span className="text-xs font-semibold uppercase" style={{ color: priorityColor }}>
{item.priority || 'NORMAL'}
</span>
</div>
{/* Status and Progress */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{item.status_i18n
? (() => {
const statusKey = item.status_i18n.key;
const statusNamespace = statusKey.startsWith('production.') ? 'production' : 'reasoning';
const statusTranslationKey = statusKey.startsWith('production.')
? statusKey.substring('production.'.length)
: (statusKey.startsWith('reasoning.') ? statusKey.substring('reasoning.'.length) : statusKey);
const fullStatusKey = `${statusNamespace}:${statusTranslationKey}`;
return String(t(fullStatusKey, item.status_i18n.params) || item.statusText || 'Status');
})()
: item.statusText || 'Status'}
</span>
{item.status === 'IN_PROGRESS' && (
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>{item.progress || 0}%</span>
)}
</div>
{/* Progress Bar */}
{item.status === 'IN_PROGRESS' && (
<div className="w-full rounded-full h-2" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div
className="h-2 rounded-full transition-all duration-500"
style={{ width: `${item.progress || 0}%`, backgroundColor: 'var(--color-info-600)' }}
/>
</div>
)}
</div>
{/* Ready By Time */}
{item.status !== 'COMPLETED' && (
<div className="flex items-center gap-2 text-sm mb-2" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-4 h-4" />
<span>
{t('jtbd.production_timeline.ready_by')}: {readyByTime}
</span>
</div>
)}
{/* Reasoning */}
{reasoning && (
<p className="text-sm italic mb-3" style={{ color: 'var(--text-secondary)' }}>"{reasoning}"</p>
)}
{/* Actions */}
{item.status === 'PENDING' && onStart && (
<button
onClick={() => onStart(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--color-success-600)',
color: 'var(--text-inverse)',
}}
>
<Play className="w-4 h-4" />
{t('jtbd.production_timeline.start_batch')}
</button>
)}
{item.status === 'IN_PROGRESS' && onPause && (
<button
onClick={() => onPause(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--color-warning-600)',
color: 'var(--text-inverse)',
}}
>
<Pause className="w-4 h-4" />
{t('jtbd.production_timeline.pause_batch')}
</button>
)}
{item.status === 'COMPLETED' && (
<div className="flex items-center gap-2 text-sm font-medium" style={{ color: 'var(--color-success-600)' }}>
<CheckCircle2 className="w-4 h-4" />
{t('jtbd.production_timeline.completed')}
</div>
)}
</div>
</div>
);
}
export function ProductionTimelineCard({
timeline,
loading,
onStart,
onPause,
}: ProductionTimelineCardProps) {
const { t } = useTranslation('reasoning');
// Filter for today's PENDING/ON_HOLD batches only
const filteredTimeline = useMemo(() => {
if (!timeline?.timeline) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const filteredItems = timeline.timeline.filter(item => {
// Only show PENDING or ON_HOLD status
if (item.status !== 'PENDING' && item.status !== 'ON_HOLD') {
return false;
}
// Check if plannedStartTime is today
if (item.plannedStartTime) {
const startTime = new Date(item.plannedStartTime);
return startTime >= today && startTime < tomorrow;
}
return false;
});
return {
...timeline,
timeline: filteredItems,
totalBatches: filteredItems.length,
pendingBatches: filteredItems.filter(item => item.status === 'PENDING').length,
inProgressBatches: 0, // Filtered out
completedBatches: 0, // Filtered out
};
}, [timeline]);
if (loading || !filteredTimeline) {
return (
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
<div className="animate-pulse space-y-4">
<div className="h-6 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="space-y-3">
<div className="h-24 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
<div className="h-24 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
</div>
);
}
if (!filteredTimeline.timeline || filteredTimeline.timeline.length === 0) {
return (
<div className="rounded-xl shadow-lg p-8 text-center border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
<Factory className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--text-tertiary)' }} />
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.production_timeline.no_production')}
</h3>
<p style={{ color: 'var(--text-secondary)' }}>{t('jtbd.production_timeline.no_batches')}</p>
</div>
);
}
return (
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Factory className="w-8 h-8" style={{ color: 'var(--color-info-600)' }} />
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('jtbd.production_timeline.title')}</h2>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{filteredTimeline.totalBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--text-secondary)' }}>{t('jtbd.production_timeline.total')}</div>
</div>
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--color-success-50)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-600)' }}>{filteredTimeline.completedBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--color-success-700)' }}>{t('jtbd.production_timeline.done')}</div>
</div>
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--color-info-50)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-600)' }}>{filteredTimeline.inProgressBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--color-info-700)' }}>{t('jtbd.production_timeline.active')}</div>
</div>
<div className="rounded-lg p-3 text-center" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
<div className="text-2xl font-bold" style={{ color: 'var(--text-secondary)' }}>{filteredTimeline.pendingBatches}</div>
<div className="text-xs uppercase" style={{ color: 'var(--text-secondary)' }}>{t('jtbd.production_timeline.pending')}</div>
</div>
</div>
{/* Timeline */}
<div className="space-y-4">
{filteredTimeline.timeline.map((item) => (
<TimelineItemCard
key={item.id}
item={item}
onStart={onStart}
onPause={onPause}
/>
))}
</div>
{/* View Full Schedule Link */}
{filteredTimeline.totalBatches > 5 && (
<button
className="w-full mt-6 py-3 rounded-lg font-semibold transition-colors duration-200"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
{t('jtbd.production_timeline.view_full_schedule')}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,237 @@
// ================================================================
// frontend/src/components/dashboard/SetupWizardBlocker.tsx
// ================================================================
/**
* Setup Wizard Blocker - Critical Path Onboarding
*
* JTBD: "I cannot operate my bakery without basic configuration"
*
* This component blocks the entire dashboard when critical setup is incomplete.
* Shows a full-page wizard to guide users through essential configuration.
*
* Triggers when:
* - 0-2 critical sections complete (<50% progress)
* - Missing: Ingredients (<3) OR Suppliers (<1) OR Recipes (<1)
*/
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertCircle, Package, Users, BookOpen, ChevronRight, CheckCircle2, Circle } from 'lucide-react';
interface SetupSection {
id: string;
title: string;
description: string;
icon: React.ElementType;
path: string;
isComplete: boolean;
count: number;
minimum: number;
}
interface SetupWizardBlockerProps {
criticalSections: SetupSection[];
onComplete?: () => void;
}
export function SetupWizardBlocker({ criticalSections = [], onComplete }: SetupWizardBlockerProps) {
const { t } = useTranslation(['dashboard', 'common']);
const navigate = useNavigate();
// Calculate progress
const { completedCount, totalCount, progressPercentage, nextSection } = useMemo(() => {
// Guard against undefined or invalid criticalSections
if (!criticalSections || !Array.isArray(criticalSections) || criticalSections.length === 0) {
return {
completedCount: 0,
totalCount: 0,
progressPercentage: 0,
nextSection: undefined,
};
}
const completed = criticalSections.filter(s => s.isComplete).length;
const total = criticalSections.length;
const percentage = Math.round((completed / total) * 100);
const next = criticalSections.find(s => !s.isComplete);
return {
completedCount: completed,
totalCount: total,
progressPercentage: percentage,
nextSection: next,
};
}, [criticalSections]);
const handleSectionClick = (path: string) => {
navigate(path);
};
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--bg-secondary)' }}>
<div className="w-full max-w-3xl">
{/* Warning Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full mb-4" style={{ backgroundColor: 'var(--color-warning-100)' }}>
<AlertCircle className="w-10 h-10" style={{ color: 'var(--color-warning-600)' }} />
</div>
<h1 className="text-3xl md:text-4xl font-bold mb-3" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_blocker.title', 'Configuración Requerida')}
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:setup_blocker.subtitle', 'Necesitas completar la configuración básica antes de usar el panel de control')}
</p>
</div>
{/* Progress Card */}
<div className="bg-[var(--bg-primary)] border-2 border-[var(--border-primary)] rounded-xl shadow-xl p-8">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between text-sm mb-3">
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_blocker.progress', 'Progreso de Configuración')}
</span>
<span className="font-bold" style={{ color: 'var(--color-primary)' }}>
{completedCount}/{totalCount} ({progressPercentage}%)
</span>
</div>
<div className="w-full h-3 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full transition-all duration-500 ease-out"
style={{
width: `${progressPercentage}%`,
background: 'linear-gradient(90deg, var(--color-primary), var(--color-success))',
}}
/>
</div>
</div>
{/* Critical Sections List */}
<div className="space-y-4 mb-8">
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:setup_blocker.required_steps', 'Pasos Requeridos')}
</h3>
{criticalSections.map((section, index) => {
const Icon = section.icon || (() => <div></div>);
const isNext = section === nextSection;
return (
<button
key={section.id}
onClick={() => handleSectionClick(section.path)}
className={`w-full p-5 rounded-lg border-2 transition-all duration-200 text-left group ${
section.isComplete
? 'border-[var(--color-success)]/30 bg-[var(--color-success)]/5'
: isNext
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5 hover:bg-[var(--color-primary)]/10 shadow-md'
: 'border-[var(--border-secondary)] bg-[var(--bg-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-primary)]'
}`}
>
<div className="flex items-start gap-4">
{/* Step Number / Status */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${
section.isComplete
? 'bg-[var(--color-success)] text-white'
: isNext
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
}`}>
{section.isComplete ? (
<CheckCircle2 className="w-5 h-5" />
) : (
<span>{index + 1}</span>
)}
</div>
{/* Section Icon */}
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
section.isComplete
? 'bg-[var(--color-success)]/20 text-[var(--color-success)]'
: isNext
? 'bg-[var(--color-primary)]/20 text-[var(--color-primary)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
}`}>
<Icon className="w-6 h-6" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
{section.title}
</h4>
{isNext && !section.isComplete && (
<span className="px-2 py-0.5 bg-[var(--color-primary)] text-white rounded-full text-xs font-semibold">
{t('dashboard:setup_blocker.next', 'Siguiente')}
</span>
)}
</div>
<p className="text-sm mb-2" style={{ color: 'var(--text-secondary)' }}>
{section.description}
</p>
<div className="flex items-center gap-2 text-sm">
{section.isComplete ? (
<span className="font-semibold" style={{ color: 'var(--color-success)' }}>
{section.count} {t('dashboard:setup_blocker.added', 'agregado(s)')}
</span>
) : (
<span className="font-semibold" style={{ color: 'var(--color-warning-700)' }}>
{t('dashboard:setup_blocker.minimum_required', 'Mínimo requerido')}: {section.minimum}
</span>
)}
</div>
</div>
{/* Chevron */}
<ChevronRight className={`w-6 h-6 flex-shrink-0 transition-transform group-hover:translate-x-1 ${
isNext ? 'text-[var(--color-primary)]' : 'text-[var(--text-tertiary)]'
}`} />
</div>
</button>
);
})}
</div>
{/* Next Step CTA */}
{nextSection && (
<div className="p-5 rounded-xl border-2 border-[var(--color-primary)]/30" style={{ backgroundColor: 'var(--color-primary)]/5' }}>
<div className="flex items-start gap-4">
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--color-primary)' }} />
<div className="flex-1">
<h4 className="font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
👉 {t('dashboard:setup_blocker.start_with', 'Empieza por')}: {nextSection.title}
</h4>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{nextSection.description}
</p>
<button
onClick={() => handleSectionClick(nextSection.path)}
className="px-6 py-3 rounded-lg font-semibold transition-all duration-200 hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl inline-flex items-center gap-2"
style={{
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
color: 'white',
}}
>
{t('dashboard:setup_blocker.configure_now', 'Configurar Ahora')} {nextSection.title}
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
{/* Help Text */}
<div className="mt-6 pt-6 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm text-center" style={{ color: 'var(--text-tertiary)' }}>
💡 {t('dashboard:setup_blocker.help_text', 'Una vez completes estos pasos, podrás acceder al panel de control completo y comenzar a usar todas las funciones de IA')}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,677 @@
// ================================================================
// frontend/src/components/dashboard/StockReceiptModal.tsx
// ================================================================
/**
* Stock Receipt Modal - Lot-Level Tracking
*
* Complete workflow for receiving deliveries with lot-level expiration tracking.
* Critical for food safety compliance.
*
* Features:
* - Multi-line item support (one per ingredient)
* - Lot splitting (e.g., 50kg → 2×25kg lots with different expiration dates)
* - Mandatory expiration dates
* - Quantity validation (lot quantities must sum to actual quantity)
* - Discrepancy tracking (expected vs actual)
* - Draft save functionality
* - Warehouse location tracking
*/
import React, { useState, useEffect } from 'react';
import {
X,
Plus,
Trash2,
Save,
CheckCircle,
AlertTriangle,
Package,
Calendar,
MapPin,
FileText,
Truck,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui/Button';
// ============================================================
// Types
// ============================================================
export interface StockLot {
id?: string;
lot_number?: string;
supplier_lot_number?: string;
quantity: number;
unit_of_measure: string;
expiration_date: string; // ISO date string (YYYY-MM-DD)
best_before_date?: string;
warehouse_location?: string;
storage_zone?: string;
quality_notes?: string;
}
export interface StockReceiptLineItem {
id?: string;
ingredient_id: string;
ingredient_name: string;
po_line_id?: string;
expected_quantity: number;
actual_quantity: number;
unit_of_measure: string;
has_discrepancy: boolean;
discrepancy_reason?: string;
unit_cost?: number;
total_cost?: number;
lots: StockLot[];
}
export interface StockReceipt {
id?: string;
tenant_id: string;
po_id: string;
po_number?: string;
received_at?: string;
received_by_user_id: string;
status?: 'draft' | 'confirmed' | 'cancelled';
supplier_id?: string;
supplier_name?: string;
notes?: string;
has_discrepancies?: boolean;
line_items: StockReceiptLineItem[];
}
interface StockReceiptModalProps {
isOpen: boolean;
onClose: () => void;
receipt: Partial<StockReceipt>;
mode?: 'create' | 'edit';
onSaveDraft?: (receipt: StockReceipt) => Promise<void>;
onConfirm?: (receipt: StockReceipt) => Promise<void>;
}
// ============================================================
// Helper Functions
// ============================================================
function calculateLotQuantitySum(lots: StockLot[]): number {
return lots.reduce((sum, lot) => sum + (lot.quantity || 0), 0);
}
function hasDiscrepancy(expected: number, actual: number): boolean {
return Math.abs(expected - actual) > 0.01;
}
// ============================================================
// Sub-Components
// ============================================================
interface LotInputProps {
lot: StockLot;
lineItemUoM: string;
onChange: (updatedLot: StockLot) => void;
onRemove: () => void;
canRemove: boolean;
}
function LotInput({ lot, lineItemUoM, onChange, onRemove, canRemove }: LotInputProps) {
const { t } = useTranslation('inventory');
return (
<div
className="p-4 rounded-lg border-2"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-secondary)',
}}
>
{/* Lot Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Package className="w-4 h-4" style={{ color: 'var(--color-info)' }} />
<span className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{t('lot_details')}
</span>
</div>
{canRemove && (
<button
onClick={onRemove}
className="p-1 rounded hover:bg-red-100 transition-colors"
style={{ color: 'var(--color-error)' }}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Quantity (Required) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('quantity')} ({lineItemUoM}) *
</label>
<input
type="number"
step="0.01"
min="0"
value={lot.quantity || ''}
onChange={(e) => onChange({ ...lot, quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
required
/>
</div>
{/* Expiration Date (Required) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Calendar className="w-3 h-3 inline mr-1" />
{t('expiration_date')} *
</label>
<input
type="date"
value={lot.expiration_date || ''}
onChange={(e) => onChange({ ...lot, expiration_date: e.target.value })}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
required
/>
</div>
{/* Lot Number (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('lot_number')}
</label>
<input
type="text"
value={lot.lot_number || ''}
onChange={(e) => onChange({ ...lot, lot_number: e.target.value })}
placeholder="LOT-2024-001"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Supplier Lot Number (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('supplier_lot_number')}
</label>
<input
type="text"
value={lot.supplier_lot_number || ''}
onChange={(e) => onChange({ ...lot, supplier_lot_number: e.target.value })}
placeholder="SUPP-LOT-123"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Warehouse Location (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<MapPin className="w-3 h-3 inline mr-1" />
{t('warehouse_location')}
</label>
<input
type="text"
value={lot.warehouse_location || ''}
onChange={(e) => onChange({ ...lot, warehouse_location: e.target.value })}
placeholder="A-01-03"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Storage Zone (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('storage_zone')}
</label>
<input
type="text"
value={lot.storage_zone || ''}
onChange={(e) => onChange({ ...lot, storage_zone: e.target.value })}
placeholder="Cold Storage"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
{/* Quality Notes (Optional, full width) */}
<div className="mt-3">
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<FileText className="w-3 h-3 inline mr-1" />
{t('quality_notes')}
</label>
<textarea
value={lot.quality_notes || ''}
onChange={(e) => onChange({ ...lot, quality_notes: e.target.value })}
placeholder={t('quality_notes_placeholder')}
rows={2}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
);
}
// ============================================================
// Main Component
// ============================================================
export function StockReceiptModal({
isOpen,
onClose,
receipt: initialReceipt,
mode = 'create',
onSaveDraft,
onConfirm,
}: StockReceiptModalProps) {
const { t } = useTranslation(['inventory', 'common']);
const [receipt, setReceipt] = useState<Partial<StockReceipt>>(initialReceipt);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setReceipt(initialReceipt);
}, [initialReceipt]);
if (!isOpen) return null;
// ============================================================
// Handlers
// ============================================================
const updateLineItem = (index: number, updates: Partial<StockReceiptLineItem>) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[index] = { ...newLineItems[index], ...updates };
// Auto-detect discrepancy
if (updates.actual_quantity !== undefined || updates.expected_quantity !== undefined) {
const item = newLineItems[index];
item.has_discrepancy = hasDiscrepancy(item.expected_quantity, item.actual_quantity);
}
setReceipt({ ...receipt, line_items: newLineItems });
};
const updateLot = (lineItemIndex: number, lotIndex: number, updatedLot: StockLot) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots[lotIndex] = updatedLot;
setReceipt({ ...receipt, line_items: newLineItems });
};
const addLot = (lineItemIndex: number) => {
const lineItem = receipt.line_items?.[lineItemIndex];
if (!lineItem) return;
const newLot: StockLot = {
quantity: 0,
unit_of_measure: lineItem.unit_of_measure,
expiration_date: '',
};
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots.push(newLot);
setReceipt({ ...receipt, line_items: newLineItems });
};
const removeLot = (lineItemIndex: number, lotIndex: number) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots.splice(lotIndex, 1);
setReceipt({ ...receipt, line_items: newLineItems });
};
const validate = (): boolean => {
const errors: Record<string, string> = {};
if (!receipt.line_items || receipt.line_items.length === 0) {
errors.general = t('inventory:validation.no_line_items');
setValidationErrors(errors);
return false;
}
receipt.line_items.forEach((item, idx) => {
// Check lots exist
if (!item.lots || item.lots.length === 0) {
errors[`line_${idx}_lots`] = t('inventory:validation.at_least_one_lot');
}
// Check lot quantities sum to actual quantity
const lotSum = calculateLotQuantitySum(item.lots);
if (Math.abs(lotSum - item.actual_quantity) > 0.01) {
errors[`line_${idx}_quantity`] = t('inventory:validation.lot_quantity_mismatch', {
expected: item.actual_quantity,
actual: lotSum,
});
}
// Check expiration dates
item.lots.forEach((lot, lotIdx) => {
if (!lot.expiration_date) {
errors[`line_${idx}_lot_${lotIdx}_exp`] = t('inventory:validation.expiration_required');
}
if (!lot.quantity || lot.quantity <= 0) {
errors[`line_${idx}_lot_${lotIdx}_qty`] = t('inventory:validation.quantity_required');
}
});
});
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSaveDraft = async () => {
if (!onSaveDraft) return;
setIsSaving(true);
try {
await onSaveDraft(receipt as StockReceipt);
onClose();
} catch (error) {
console.error('Failed to save draft:', error);
setValidationErrors({ general: t('inventory:errors.save_failed') });
} finally {
setIsSaving(false);
}
};
const handleConfirm = async () => {
if (!validate()) return;
if (!onConfirm) return;
setIsSaving(true);
try {
await onConfirm(receipt as StockReceipt);
onClose();
} catch (error) {
console.error('Failed to confirm receipt:', error);
setValidationErrors({ general: t('inventory:errors.confirm_failed') });
} finally {
setIsSaving(false);
}
};
// ============================================================
// Render
// ============================================================
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-5xl max-h-[90vh] overflow-hidden rounded-xl shadow-2xl flex flex-col"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
{/* Header */}
<div
className="flex items-center justify-between p-6 border-b"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" style={{ color: 'var(--color-primary)' }} />
<div>
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{t('inventory:stock_receipt_title')}
</h2>
{receipt.po_number && (
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:purchase_order')}: {receipt.po_number}
</p>
)}
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Supplier Info */}
{receipt.supplier_name && (
<div
className="mb-6 p-4 rounded-lg border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-secondary)',
}}
>
<h3 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('inventory:supplier_info')}
</h3>
<p style={{ color: 'var(--text-secondary)' }}>{receipt.supplier_name}</p>
</div>
)}
{/* Validation Errors */}
{validationErrors.general && (
<div
className="mb-6 p-4 rounded-lg border-2 flex items-start gap-3"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
}}
>
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-error-700)' }} />
<div>
<p className="font-semibold" style={{ color: 'var(--color-error-900)' }}>
{validationErrors.general}
</p>
</div>
</div>
)}
{/* Line Items */}
{receipt.line_items?.map((item, idx) => (
<div
key={idx}
className="mb-6 p-6 rounded-xl border-2"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.has_discrepancy ? 'var(--color-warning-300)' : 'var(--border-primary)',
}}
>
{/* Line Item Header */}
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{item.ingredient_name}
</h3>
<div className="flex items-center gap-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>
{t('inventory:expected')}: {item.expected_quantity} {item.unit_of_measure}
</span>
<span></span>
<span className={item.has_discrepancy ? 'font-semibold' : ''}>
{t('inventory:actual')}: {item.actual_quantity} {item.unit_of_measure}
</span>
</div>
{item.has_discrepancy && (
<div className="mt-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" style={{ color: 'var(--color-warning-700)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-warning-900)' }}>
{t('inventory:discrepancy_detected')}
</span>
</div>
)}
</div>
{/* Lot Quantity Summary */}
<div className="text-right">
<div className="text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:lot_total')}
</div>
<div
className="text-2xl font-bold"
style={{
color:
Math.abs(calculateLotQuantitySum(item.lots) - item.actual_quantity) < 0.01
? 'var(--color-success)'
: 'var(--color-error)',
}}
>
{calculateLotQuantitySum(item.lots).toFixed(2)} {item.unit_of_measure}
</div>
</div>
</div>
{/* Discrepancy Reason */}
{item.has_discrepancy && (
<div className="mb-4">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:discrepancy_reason')}
</label>
<textarea
value={item.discrepancy_reason || ''}
onChange={(e) => updateLineItem(idx, { discrepancy_reason: e.target.value })}
placeholder={t('inventory:discrepancy_reason_placeholder')}
rows={2}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
)}
{/* Lots */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('inventory:lots')} ({item.lots.length})
</h4>
</div>
{item.lots.map((lot, lotIdx) => (
<LotInput
key={lotIdx}
lot={lot}
lineItemUoM={item.unit_of_measure}
onChange={(updatedLot) => updateLot(idx, lotIdx, updatedLot)}
onRemove={() => removeLot(idx, lotIdx)}
canRemove={item.lots.length > 1}
/>
))}
{/* Add Lot Button */}
<button
onClick={() => addLot(idx)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed transition-colors hover:border-solid"
style={{
borderColor: 'var(--border-primary)',
color: 'var(--color-primary)',
backgroundColor: 'transparent',
}}
>
<Plus className="w-4 h-4" />
<span className="font-semibold">{t('inventory:add_lot')}</span>
</button>
</div>
{/* Validation Error for this line */}
{(validationErrors[`line_${idx}_lots`] ||
validationErrors[`line_${idx}_quantity`]) && (
<div
className="mt-4 p-3 rounded-lg border flex items-start gap-2"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
}}
>
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-error-700)' }} />
<p className="text-sm" style={{ color: 'var(--color-error-900)' }}>
{validationErrors[`line_${idx}_lots`] ||
validationErrors[`line_${idx}_quantity`]}
</p>
</div>
)}
</div>
))}
{/* General Notes */}
<div className="mt-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:receipt_notes')}
</label>
<textarea
value={receipt.notes || ''}
onChange={(e) => setReceipt({ ...receipt, notes: e.target.value })}
placeholder={t('inventory:receipt_notes_placeholder')}
rows={3}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
{/* Footer */}
<div
className="flex items-center justify-between p-6 border-t"
style={{ borderColor: 'var(--border-primary)' }}
>
<Button variant="ghost" onClick={onClose} disabled={isSaving}>
{t('common:actions.cancel')}
</Button>
<div className="flex items-center gap-3">
{onSaveDraft && (
<Button variant="secondary" onClick={handleSaveDraft} disabled={isSaving}>
<Save className="w-4 h-4 mr-2" />
{t('common:actions.save_draft')}
</Button>
)}
{onConfirm && (
<Button variant="default" onClick={handleConfirm} disabled={isSaving}>
<CheckCircle className="w-4 h-4 mr-2" />
{t('common:actions.confirm_receipt')}
</Button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -95,11 +95,7 @@ function EscalationBadge({ alert }: { alert: EnrichedAlert }) {
return ( return (
<div <div
className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold" className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold bg-[var(--color-warning-100)] text-[var(--color-warning-900)]"
style={{
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-900)',
}}
> >
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
<span> <span>
@@ -267,7 +263,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{showEscalationBadge && <EscalationBadge alert={alert} />} {showEscalationBadge && <EscalationBadge alert={alert} />}
{/* Message */} {/* Message */}
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm mb-3 text-[var(--text-secondary)]">
{alert.message} {alert.message}
</p> </p>
@@ -276,11 +272,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
<div className="flex flex-wrap gap-2 mb-3"> <div className="flex flex-wrap gap-2 mb-3">
{alert.business_impact?.financial_impact_eur && ( {alert.business_impact?.financial_impact_eur && (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold" 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)]"
style={{
backgroundColor: 'var(--color-warning-100)',
color: 'var(--color-warning-800)',
}}
> >
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} at risk</span> <span>{alert.business_impact.financial_impact_eur.toFixed(0)} at risk</span>
@@ -290,11 +282,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
<div <div
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold ${ 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' : '' alert.urgency_context.time_until_consequence_hours < 6 ? 'animate-pulse' : ''
}`} } bg-[var(--color-error-100)] text-[var(--color-error-800)]`}
style={{
backgroundColor: 'var(--color-error-100)',
color: 'var(--color-error-800)',
}}
> >
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{Math.round(alert.urgency_context.time_until_consequence_hours)}h left</span> <span>{Math.round(alert.urgency_context.time_until_consequence_hours)}h left</span>
@@ -302,11 +290,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
)} )}
{alert.type_class === 'prevented_issue' && ( {alert.type_class === 'prevented_issue' && (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold" 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)]"
style={{
backgroundColor: 'var(--color-success-100)',
color: 'var(--color-success-800)',
}}
> >
<Bot className="w-4 h-4" /> <Bot className="w-4 h-4" />
<span>AI handled</span> <span>AI handled</span>
@@ -320,8 +304,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
<> <>
<button <button
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-2" className="flex items-center gap-2 text-sm font-medium transition-colors mb-2 text-[var(--color-info-700)]"
style={{ color: 'var(--color-info-700)' }}
> >
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />
@@ -330,15 +313,11 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{expanded && ( {expanded && (
<div <div
className="rounded-md p-3 mb-3" className="rounded-md p-3 mb-3 bg-[var(--bg-secondary)] border-l-4 border-l-[var(--color-info-600)]"
style={{
backgroundColor: 'var(--bg-secondary)',
borderLeft: '3px solid var(--color-info-600)',
}}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Bot className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-info-700)' }} /> <Bot className="w-4 h-4 flex-shrink-0 mt-0.5 text-[var(--color-info-700)]" />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm text-[var(--text-secondary)]">
{alert.ai_reasoning_summary} {alert.ai_reasoning_summary}
</p> </p>
</div> </div>
@@ -386,11 +365,7 @@ function ActionCard({ alert, showEscalationBadge = false, onActionSuccess, onAct
{/* Action Completed State */} {/* Action Completed State */}
{actionCompleted && ( {actionCompleted && (
<div <div
className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md" className="flex items-center gap-2 mt-3 px-3 py-2 rounded-md bg-[var(--color-success-100)] text-[var(--color-success-800)]"
style={{
backgroundColor: 'var(--color-success-100)',
color: 'var(--color-success-800)',
}}
> >
<CheckCircle className="w-5 h-5" /> <CheckCircle className="w-5 h-5" />
<span className="font-semibold">Action completed successfully</span> <span className="font-semibold">Action completed successfully</span>
@@ -443,7 +418,7 @@ function ActionSection({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="w-5 h-5" style={{ color: iconColor }} /> <Icon className="w-5 h-5" style={{ color: iconColor }} />
<h3 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}> <h3 className="text-lg font-bold text-[var(--text-primary)]">
{title} {title}
</h3> </h3>
<span <span
@@ -457,9 +432,9 @@ function ActionSection({
</span> </span>
</div> </div>
{expanded ? ( {expanded ? (
<ChevronUp className="w-5 h-5 transition-transform" style={{ color: 'var(--text-secondary)' }} /> <ChevronUp className="w-5 h-5 transition-transform text-[var(--text-secondary)]" />
) : ( ) : (
<ChevronDown className="w-5 h-5 transition-transform" style={{ color: 'var(--text-secondary)' }} /> <ChevronDown className="w-5 h-5 transition-transform text-[var(--text-secondary)]" />
)} )}
</button> </button>
@@ -674,9 +649,9 @@ export function UnifiedActionQueueCard({
}} }}
> >
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
<div className="h-6 rounded w-1/2" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div> <div className="h-6 rounded w-1/2 bg-[var(--bg-tertiary)]"></div>
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div> <div className="h-32 rounded bg-[var(--bg-tertiary)]"></div>
<div className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div> <div className="h-32 rounded bg-[var(--bg-tertiary)]"></div>
</div> </div>
</div> </div>
); );
@@ -685,19 +660,15 @@ export function UnifiedActionQueueCard({
if (!displayQueue || displayQueue.totalActions === 0) { if (!displayQueue || displayQueue.totalActions === 0) {
return ( return (
<div <div
className="border-2 rounded-xl p-8 text-center shadow-lg" className="border-2 rounded-xl p-8 text-center shadow-lg bg-[var(--color-success-50)] border-[var(--color-success-200)]"
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-200)',
}}
> >
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--color-success-100)' }}> <div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center bg-[var(--color-success-100)]">
<Zap className="w-8 h-8" style={{ color: 'var(--color-success-600)' }} /> <Zap className="w-8 h-8 text-[var(--color-success-600)]" />
</div> </div>
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}> <h3 className="text-xl font-bold mb-2 text-[var(--color-success-900)]">
{t('dashboard:all_caught_up')} {t('dashboard:all_caught_up')}
</h3> </h3>
<p style={{ color: 'var(--color-success-700)' }}> <p className="text-[var(--color-success-700)]">
{t('dashboard:no_actions_needed')} {t('dashboard:no_actions_needed')}
</p> </p>
</div> </div>
@@ -706,11 +677,7 @@ export function UnifiedActionQueueCard({
return ( return (
<div <div
className="rounded-xl shadow-xl p-6 border-2 transition-all duration-300 hover:shadow-2xl" className="rounded-xl shadow-xl p-6 border-2 transition-all duration-300 hover:shadow-2xl bg-[var(--bg-primary)] border-[var(--border-primary)]"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
> >
{/* Header with Hero Icon */} {/* Header with Hero Icon */}
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
@@ -728,7 +695,7 @@ export function UnifiedActionQueueCard({
{/* Title + Inline Metrics */} {/* Title + Inline Metrics */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}> <h2 className="text-2xl md:text-3xl font-bold mb-2 text-[var(--text-primary)]">
{t('dashboard:action_queue_title')} {t('dashboard:action_queue_title')}
</h2> </h2>
@@ -736,12 +703,11 @@ export function UnifiedActionQueueCard({
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Total Actions Badge */} {/* Total Actions Badge */}
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md" className={`flex items-center gap-1.5 px-2 py-1 rounded-md ${
style={{ displayQueue.totalActions > 5
backgroundColor: displayQueue.totalActions > 5 ? 'var(--color-error-100)' : 'var(--color-info-100)', ? 'bg-[var(--color-error-100)] text-[var(--color-error-800)] border border-[var(--color-error-300)]'
color: displayQueue.totalActions > 5 ? 'var(--color-error-800)' : 'var(--color-info-800)', : 'bg-[var(--color-info-100)] text-[var(--color-info-800)] border border-[var(--color-info-300)]'
border: `1px solid ${displayQueue.totalActions > 5 ? 'var(--color-error-300)' : 'var(--color-info-300)'}`, }`}
}}
> >
<span className="font-semibold">{displayQueue.totalActions}</span> <span className="font-semibold">{displayQueue.totalActions}</span>
<span className="text-xs">{t('dashboard:total_actions')}</span> <span className="text-xs">{t('dashboard:total_actions')}</span>
@@ -749,12 +715,11 @@ export function UnifiedActionQueueCard({
{/* SSE Connection Badge */} {/* SSE Connection Badge */}
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs" className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs ${
style={{ isConnected
backgroundColor: isConnected ? 'var(--color-success-100)' : 'var(--color-error-100)', ? 'bg-[var(--color-success-100)] text-[var(--color-success-800)] border border-[var(--color-success-300)]'
color: isConnected ? 'var(--color-success-800)' : 'var(--color-error-800)', : 'bg-[var(--color-error-100)] text-[var(--color-error-800)] border border-[var(--color-error-300)]'
border: `1px solid ${isConnected ? 'var(--color-success-300)' : 'var(--color-error-300)'}`, }`}
}}
> >
{isConnected ? ( {isConnected ? (
<> <>
@@ -775,11 +740,11 @@ export function UnifiedActionQueueCard({
{/* Toast Notification */} {/* Toast Notification */}
{toastMessage && ( {toastMessage && (
<div <div
className="fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in-right" className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-slide-in-right text-white ${
style={{ toastMessage.type === 'success'
backgroundColor: toastMessage.type === 'success' ? 'var(--color-success-600)' : 'var(--color-error-600)', ? 'bg-[var(--color-success-600)]'
color: 'white', : 'bg-[var(--color-error-600)]'
}} }`}
> >
{toastMessage.type === 'success' ? ( {toastMessage.type === 'success' ? (
<CheckCircle className="w-5 h-5" /> <CheckCircle className="w-5 h-5" />

View File

@@ -6,8 +6,15 @@
* Barrel export for all JTBD dashboard components * Barrel export for all JTBD dashboard components
*/ */
export { HealthStatusCard } from './HealthStatusCard'; // Core Dashboard Components (JTBD-Aligned)
export { ActionQueueCard } from './ActionQueueCard'; export { GlanceableHealthHero } from './GlanceableHealthHero';
export { OrchestrationSummaryCard } from './OrchestrationSummaryCard'; export { UnifiedActionQueueCard } from './UnifiedActionQueueCard';
export { ProductionTimelineCard } from './ProductionTimelineCard'; export { ExecutionProgressTracker } from './ExecutionProgressTracker';
export { InsightsGrid } from './InsightsGrid'; export { IntelligentSystemSummaryCard } from './IntelligentSystemSummaryCard';
// Setup Flow Components
export { SetupWizardBlocker } from './SetupWizardBlocker';
export { CollapsibleSetupBanner } from './CollapsibleSetupBanner';
// Modals & Utilities
export { StockReceiptModal } from './StockReceiptModal';

View File

@@ -1,126 +0,0 @@
import React, { useState } from 'react';
import { Check, Trash2, Clock, X } from 'lucide-react';
import { Button } from '../../ui/Button';
import AlertSnoozeMenu from './AlertSnoozeMenu';
export interface AlertBulkActionsProps {
selectedCount: number;
onMarkAsRead: () => void;
onRemove: () => void;
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
onDeselectAll: () => void;
onSelectAll: () => void;
totalCount: number;
}
const AlertBulkActions: React.FC<AlertBulkActionsProps> = ({
selectedCount,
onMarkAsRead,
onRemove,
onSnooze,
onDeselectAll,
onSelectAll,
totalCount,
}) => {
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
if (selectedCount === 0) {
return null;
}
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
onSnooze(duration);
setShowSnoozeMenu(false);
};
const allSelected = selectedCount === totalCount;
return (
<div className="sticky top-0 z-20 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white px-4 py-3 rounded-xl shadow-xl flex items-center justify-between gap-3 animate-in slide-in-from-top-2 duration-300">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg">
<span className="text-sm font-bold">{selectedCount}</span>
<span className="text-xs font-medium opacity-90">
{selectedCount === 1 ? 'seleccionado' : 'seleccionados'}
</span>
</div>
{!allSelected && totalCount > selectedCount && (
<button
onClick={onSelectAll}
className="text-sm font-medium hover:underline opacity-90 hover:opacity-100 transition-opacity whitespace-nowrap"
aria-label={`Select all ${totalCount} alerts`}
>
Seleccionar todos ({totalCount})
</button>
)}
</div>
<div className="flex items-center gap-2 relative flex-shrink-0">
{/* Quick Actions */}
<Button
variant="outline"
size="sm"
onClick={onMarkAsRead}
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
aria-label="Mark all selected as read"
>
<Check className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Marcar leídos</span>
</Button>
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
aria-label="Snooze selected alerts"
>
<Clock className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Posponer</span>
</Button>
{showSnoozeMenu && (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setShowSnoozeMenu(false)}
aria-hidden="true"
/>
<div className="absolute right-0 top-full mt-2 z-30">
<AlertSnoozeMenu
onSnooze={handleSnooze}
onCancel={() => setShowSnoozeMenu(false)}
/>
</div>
</>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={onRemove}
className="bg-red-500/25 text-white border-red-300/40 hover:bg-red-500/40 backdrop-blur-sm h-9 px-3"
aria-label="Delete selected alerts"
>
<Trash2 className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Eliminar</span>
</Button>
{/* Close button */}
<button
onClick={onDeselectAll}
className="ml-1 p-2 hover:bg-white/15 rounded-lg transition-colors"
aria-label="Deselect all"
title="Cerrar selección"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default AlertBulkActions;

View File

@@ -1,446 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
AlertCircle,
Info,
CheckCircle,
Check,
Trash2,
Clock,
MoreVertical,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import type { NotificationData } from '../../../hooks/useNotifications';
import { getSnoozedTimeRemaining, categorizeAlert } from '../../../utils/alertHelpers';
import AlertContextActions from './AlertContextActions';
import AlertSnoozeMenu from './AlertSnoozeMenu';
export interface AlertCardProps {
alert: NotificationData;
isExpanded: boolean;
isSelected: boolean;
isSnoozed: boolean;
snoozedUntil?: number;
onToggleExpand: () => void;
onToggleSelect: () => void;
onMarkAsRead: () => void;
onRemove: () => void;
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
onUnsnooze: () => void;
showCheckbox?: boolean;
}
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'urgent':
return AlertTriangle;
case 'high':
return AlertCircle;
case 'medium':
return Info;
case 'low':
return CheckCircle;
default:
return Info;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'urgent':
return 'var(--color-error)';
case 'high':
return 'var(--color-warning)';
case 'medium':
return 'var(--color-info)';
case 'low':
return 'var(--color-success)';
default:
return 'var(--color-info)';
}
};
const getSeverityBadge = (severity: string): 'error' | 'warning' | 'info' | 'success' => {
switch (severity) {
case 'urgent':
return 'error';
case 'high':
return 'warning';
case 'medium':
return 'info';
case 'low':
return 'success';
default:
return 'info';
}
};
const formatTimestamp = (timestamp: string, t: any) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora');
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
? t('dashboard:alerts.time.yesterday', 'Ayer')
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
};
const AlertCard: React.FC<AlertCardProps> = ({
alert,
isExpanded,
isSelected,
isSnoozed,
snoozedUntil,
onToggleExpand,
onToggleSelect,
onMarkAsRead,
onRemove,
onSnooze,
onUnsnooze,
showCheckbox = false,
}) => {
const { t } = useTranslation(['dashboard']);
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
const [showActions, setShowActions] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const SeverityIcon = getSeverityIcon(alert.severity);
const severityColor = getSeverityColor(alert.severity);
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
onSnooze(duration);
setShowSnoozeMenu(false);
};
const category = categorizeAlert(alert);
return (
<div
className={`
rounded-lg transition-all duration-200 relative overflow-hidden
${isExpanded ? 'shadow-md' : 'hover:shadow-md'}
${isSelected ? 'ring-2 ring-[var(--color-primary)] ring-offset-2' : ''}
${isSnoozed ? 'opacity-75' : ''}
`}
style={{
backgroundColor: 'var(--bg-primary)',
border: '1px solid var(--border-primary)',
...(isExpanded && {
backgroundColor: 'var(--bg-secondary)',
}),
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Left severity accent border */}
<div
className="absolute left-0 top-0 bottom-0 w-1"
style={{ backgroundColor: severityColor }}
/>
{/* Compact Card Header */}
<div className="flex items-start gap-3 p-4 pl-5">
{/* Checkbox for selection */}
{showCheckbox && (
<div className="flex-shrink-0 pt-0.5">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggleSelect();
}}
className="w-4 h-4 rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)] focus:ring-offset-0 cursor-pointer"
aria-label={`Select alert: ${alert.title}`}
/>
</div>
)}
{/* Severity Icon */}
<div
className="flex-shrink-0 p-2 rounded-lg cursor-pointer hover:scale-105 transition-transform"
style={{ backgroundColor: severityColor + '15' }}
onClick={onToggleExpand}
aria-label="Toggle alert details"
>
<SeverityIcon
className="w-5 h-5"
style={{ color: severityColor }}
/>
</div>
{/* Alert Content */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
{/* Title and Status */}
<div className="flex items-start justify-between gap-3 mb-1.5">
<div className="flex-1 min-w-0">
<h4 className="text-base font-semibold leading-snug mb-1" style={{ color: 'var(--text-primary)' }}>
{alert.title}
</h4>
<div className="flex items-center gap-2 flex-wrap">
{/* Single primary severity badge */}
<Badge variant={getSeverityBadge(alert.severity)} size="sm" className="font-semibold px-2.5 py-1 min-h-[1.375rem]">
{t(`dashboard:alerts.severity.${alert.severity}`, alert.severity.toUpperCase())}
</Badge>
{/* Unread indicator */}
{!alert.read && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/20 min-h-[1.375rem]" style={{ color: 'var(--color-info)' }}>
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse flex-shrink-0" />
{t('dashboard:alerts.status.new', 'Nuevo')}
</span>
)}
{/* Snoozed indicator */}
{isSnoozed && snoozedUntil && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-gray-50 dark:bg-gray-800 min-h-[1.375rem]" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-3 h-3 flex-shrink-0" />
<span className="whitespace-nowrap">{getSnoozedTimeRemaining(alert.id, new Map([[alert.id, { alertId: alert.id, until: snoozedUntil }]]))}</span>
</span>
)}
</div>
</div>
{/* Timestamp */}
<span className="text-xs font-medium flex-shrink-0 pt-0.5" style={{ color: 'var(--text-secondary)' }}>
{formatTimestamp(alert.timestamp, t)}
</span>
</div>
{/* Preview message when collapsed */}
{!isExpanded && alert.message && (
<p
className="text-sm leading-relaxed mt-2 overflow-hidden"
style={{
color: 'var(--text-secondary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{alert.message}
</p>
)}
</div>
{/* Actions - shown on hover or when expanded */}
<div className={`flex-shrink-0 flex items-center gap-1 transition-opacity ${isHovered || isExpanded || showActions ? 'opacity-100' : 'opacity-0'}`}>
{/* Quick action buttons */}
{!alert.read && !isExpanded && (
<button
onClick={(e) => {
e.stopPropagation();
onMarkAsRead();
}}
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
title={t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
aria-label="Mark as read"
>
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setShowActions(!showActions);
}}
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
aria-label="More actions"
title="More actions"
>
<MoreVertical className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
aria-label={isExpanded ? "Collapse" : "Expand"}
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
) : (
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
</div>
</div>
{/* Quick Actions Menu - Better positioning */}
{showActions && (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setShowActions(false)}
aria-hidden="true"
/>
<div className="absolute right-3 top-16 z-30 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-1 min-w-[180px]">
{!alert.read && (
<button
onClick={(e) => {
e.stopPropagation();
onMarkAsRead();
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
Marcar como leído
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setShowSnoozeMenu(true);
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<Clock className="w-4 h-4" />
{isSnoozed ? 'Cambiar tiempo' : 'Posponer'}
</button>
{isSnoozed && (
<button
onClick={(e) => {
e.stopPropagation();
onUnsnooze();
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
style={{ color: 'var(--text-primary)' }}
>
<Clock className="w-4 h-4" />
Reactivar ahora
</button>
)}
<div className="my-1 border-t border-[var(--border-primary)]" />
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
setShowActions(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 transition-colors flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Eliminar
</button>
</div>
</>
)}
{/* Snooze Menu - Better positioning */}
{showSnoozeMenu && (
<>
<div
className="fixed inset-0 z-20"
onClick={() => setShowSnoozeMenu(false)}
aria-hidden="true"
/>
<div className="absolute right-3 top-16 z-30">
<AlertSnoozeMenu
onSnooze={handleSnooze}
onCancel={() => setShowSnoozeMenu(false)}
/>
</div>
</>
)}
{/* Expanded Details */}
{isExpanded && (
<div className="px-5 pb-4 border-t pt-4" style={{ borderColor: 'var(--border-primary)' }}>
{/* Full Message */}
<div className="mb-4">
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
{alert.message}
</p>
</div>
{/* Metadata */}
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
<div className="mb-4 p-3 rounded-lg border" style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border-primary)'
}}>
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
</p>
<div className="text-sm space-y-2" style={{ color: 'var(--text-secondary)' }}>
{Object.entries(alert.metadata).map(([key, value]) => (
<div key={key} className="flex justify-between gap-4">
<span className="font-medium capitalize text-[var(--text-primary)]">{key.replace(/_/g, ' ')}:</span>
<span className="text-right">{String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* Contextual Actions */}
<div className="mb-4">
<AlertContextActions alert={alert} />
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-wrap">
{!alert.read && (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
onMarkAsRead();
}}
className="h-9 px-4 text-sm font-medium"
>
<Check className="w-4 h-4 mr-2" />
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowSnoozeMenu(true);
}}
className="h-9 px-4 text-sm font-medium"
>
<Clock className="w-4 h-4 mr-2" />
{isSnoozed ? 'Cambiar' : 'Posponer'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-9 px-4 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4 mr-2" />
{t('dashboard:alerts.remove', 'Eliminar')}
</Button>
</div>
</div>
)}
</div>
);
};
export default AlertCard;

View File

@@ -1,49 +0,0 @@
import React from 'react';
import { Button } from '../../ui/Button';
import type { NotificationData } from '../../../hooks/useNotifications';
import { useAlertActions } from '../../../hooks/useAlertActions';
export interface AlertContextActionsProps {
alert: NotificationData;
}
const AlertContextActions: React.FC<AlertContextActionsProps> = ({ alert }) => {
const { getActions, executeAction } = useAlertActions();
const actions = getActions(alert);
if (actions.length === 0) {
return null;
}
return (
<div className="mb-4">
<p className="text-xs font-semibold mb-2 uppercase tracking-wide text-[var(--text-primary)]">
Acciones Recomendadas
</p>
<div className="flex flex-wrap gap-2">
{actions.map((action, index) => {
const variantMap: Record<string, 'primary' | 'secondary' | 'outline'> = {
primary: 'primary',
secondary: 'secondary',
outline: 'outline',
};
return (
<Button
key={index}
variant={variantMap[action.variant] || 'outline'}
size="sm"
onClick={() => executeAction(alert, action)}
className="flex items-center gap-2"
>
<span>{action.icon}</span>
<span>{action.label}</span>
</Button>
);
})}
</div>
</div>
);
};
export default AlertContextActions;

View File

@@ -1,306 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Search, X, Filter, ChevronDown } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { Input } from '../../ui/Input';
import type { AlertSeverity, AlertCategory, TimeGroup } from '../../../utils/alertHelpers';
import { getCategoryName, getCategoryIcon } from '../../../utils/alertHelpers';
export interface AlertFiltersProps {
selectedSeverities: AlertSeverity[];
selectedCategories: AlertCategory[];
selectedTimeRange: TimeGroup | 'all';
searchQuery: string;
showSnoozed: boolean;
onToggleSeverity: (severity: AlertSeverity) => void;
onToggleCategory: (category: AlertCategory) => void;
onSetTimeRange: (range: TimeGroup | 'all') => void;
onSearchChange: (query: string) => void;
onToggleShowSnoozed: () => void;
onClearFilters: () => void;
hasActiveFilters: boolean;
activeFilterCount: number;
}
const SEVERITY_CONFIG: Record<AlertSeverity, { label: string; color: string; variant: 'error' | 'warning' | 'info' | 'success' }> = {
urgent: { label: 'Urgente', color: 'bg-red-500', variant: 'error' },
high: { label: 'Alta', color: 'bg-orange-500', variant: 'warning' },
medium: { label: 'Media', color: 'bg-blue-500', variant: 'info' },
low: { label: 'Baja', color: 'bg-green-500', variant: 'success' },
};
const TIME_RANGES: Array<{ value: TimeGroup | 'all'; label: string }> = [
{ value: 'all', label: 'Todos' },
{ value: 'today', label: 'Hoy' },
{ value: 'yesterday', label: 'Ayer' },
{ value: 'this_week', label: 'Esta semana' },
{ value: 'older', label: 'Anteriores' },
];
const CATEGORIES: AlertCategory[] = ['inventory', 'production', 'orders', 'equipment', 'quality', 'suppliers'];
const AlertFilters: React.FC<AlertFiltersProps> = ({
selectedSeverities,
selectedCategories,
selectedTimeRange,
searchQuery,
showSnoozed,
onToggleSeverity,
onToggleCategory,
onSetTimeRange,
onSearchChange,
onToggleShowSnoozed,
onClearFilters,
hasActiveFilters,
activeFilterCount,
}) => {
const { t, i18n } = useTranslation(['dashboard']);
// Start collapsed by default for cleaner UI
const [showFilters, setShowFilters] = useState(false);
return (
<div className="space-y-3">
{/* Search and Filter Toggle */}
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<Input
type="text"
placeholder={t('dashboard:alerts.filters.search_placeholder', 'Buscar alertas...')}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-4 h-4" />}
rightIcon={
searchQuery ? (
<button
onClick={() => onSearchChange('')}
className="p-1 hover:bg-[var(--bg-secondary)] rounded-full transition-colors"
aria-label="Clear search"
>
<X className="w-3 h-3" />
</button>
) : undefined
}
className="pr-8 h-10"
/>
</div>
<Button
variant={showFilters || hasActiveFilters ? 'primary' : 'outline'}
size="md"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2 relative h-10 px-4"
aria-expanded={showFilters}
aria-label="Toggle filters"
>
<Filter className="w-4 h-4" />
<span className="hidden sm:inline font-medium">Filtros</span>
{activeFilterCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-[var(--color-error)] text-white text-xs font-bold rounded-full flex items-center justify-center">
{activeFilterCount}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${showFilters ? 'rotate-180' : ''}`} />
</Button>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-500 hover:text-red-600 h-10 px-3"
title="Clear all filters"
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
<span className="sr-only">Limpiar filtros</span>
</Button>
)}
</div>
{/* Expandable Filters Panel - Animated */}
{showFilters && (
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
{/* Severity Filters */}
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
{t('dashboard:alerts.filters.severity', 'Severidad')}
</label>
<div className="flex flex-wrap gap-2">
{(Object.keys(SEVERITY_CONFIG) as AlertSeverity[]).map((severity) => {
const config = SEVERITY_CONFIG[severity];
const isSelected = selectedSeverities.includes(severity);
return (
<button
key={severity}
onClick={() => onToggleSeverity(severity)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all
${isSelected
? 'ring-2 ring-[var(--color-primary)] ring-offset-2 ring-offset-[var(--bg-secondary)] scale-105'
: 'opacity-70 hover:opacity-100 hover:scale-105'
}
`}
aria-pressed={isSelected}
>
<Badge variant={config.variant} size="sm" className="pointer-events-none">
{config.label}
</Badge>
</button>
);
})}
</div>
</div>
{/* Category Filters */}
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
{t('dashboard:alerts.filters.category', 'Categoría')}
</label>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((category) => {
const isSelected = selectedCategories.includes(category);
return (
<button
key={category}
onClick={() => onToggleCategory(category)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all border-2
${isSelected
? 'bg-[var(--color-primary)] text-white border-[var(--color-primary)] scale-105'
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:text-[var(--text-primary)] hover:scale-105'
}
`}
aria-pressed={isSelected}
>
<span className="mr-1.5">{getCategoryIcon(category)}</span>
{getCategoryName(category, i18n.language)}
</button>
);
})}
</div>
</div>
{/* Time Range Filters */}
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
{t('dashboard:alerts.filters.time_range', 'Periodo')}
</label>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{TIME_RANGES.map((range) => {
const isSelected = selectedTimeRange === range.value;
return (
<button
key={range.value}
onClick={() => onSetTimeRange(range.value)}
className={`
px-4 py-2 rounded-lg text-sm font-medium transition-all
${isSelected
? 'bg-[var(--color-primary)] text-white shadow-md scale-105'
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] hover:scale-105'
}
`}
aria-pressed={isSelected}
>
{range.label}
</button>
);
})}
</div>
</div>
{/* Show Snoozed Toggle */}
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
<label htmlFor="show-snoozed-toggle" className="text-sm font-medium text-[var(--text-primary)] cursor-pointer">
{t('dashboard:alerts.filters.show_snoozed', 'Mostrar pospuestos')}
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
id="show-snoozed-toggle"
type="checkbox"
checked={showSnoozed}
onChange={onToggleShowSnoozed}
className="sr-only peer"
aria-label="Toggle show snoozed alerts"
/>
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[var(--color-primary)]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
</label>
</div>
</div>
)}
{/* Active Filters Summary - Chips */}
{hasActiveFilters && !showFilters && (
<div className="flex flex-wrap gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
<span className="text-xs font-medium text-[var(--text-secondary)] self-center">
Filtros activos:
</span>
{selectedSeverities.map((severity) => (
<button
key={severity}
onClick={() => onToggleSeverity(severity)}
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
aria-label={`Remove ${severity} filter`}
>
<Badge
variant={SEVERITY_CONFIG[severity].variant}
size="sm"
className="flex items-center gap-1.5 pr-1"
>
{SEVERITY_CONFIG[severity].label}
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
<X className="w-3 h-3" />
</span>
</Badge>
</button>
))}
{selectedCategories.map((category) => (
<button
key={category}
onClick={() => onToggleCategory(category)}
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
aria-label={`Remove ${category} filter`}
>
<Badge
variant="secondary"
size="sm"
className="flex items-center gap-1.5 pr-1"
>
{getCategoryIcon(category)} {getCategoryName(category, i18n.language)}
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
<X className="w-3 h-3" />
</span>
</Badge>
</button>
))}
{selectedTimeRange !== 'all' && (
<button
onClick={() => onSetTimeRange('all')}
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
aria-label="Remove time range filter"
>
<Badge
variant="info"
size="sm"
className="flex items-center gap-1.5 pr-1"
>
{TIME_RANGES.find(r => r.value === selectedTimeRange)?.label}
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
<X className="w-3 h-3" />
</span>
</Badge>
</button>
)}
</div>
)}
</div>
);
};
export default AlertFilters;

View File

@@ -1,84 +0,0 @@
import React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import type { AlertGroup } from '../../../utils/alertHelpers';
export interface AlertGroupHeaderProps {
group: AlertGroup;
isCollapsed: boolean;
onToggleCollapse: () => void;
}
const SEVERITY_COLORS: Record<string, string> = {
urgent: 'text-red-600 bg-red-50 border-red-200',
high: 'text-orange-600 bg-orange-50 border-orange-200',
medium: 'text-blue-600 bg-blue-50 border-blue-200',
low: 'text-green-600 bg-green-50 border-green-200',
};
const SEVERITY_BADGE_VARIANTS: Record<string, 'error' | 'warning' | 'info' | 'success'> = {
urgent: 'error',
high: 'warning',
medium: 'info',
low: 'success',
};
const AlertGroupHeader: React.FC<AlertGroupHeaderProps> = ({
group,
isCollapsed,
onToggleCollapse,
}) => {
const severityConfig = SEVERITY_COLORS[group.severity] || SEVERITY_COLORS.low;
const badgeVariant = SEVERITY_BADGE_VARIANTS[group.severity] || 'info';
return (
<button
onClick={onToggleCollapse}
className={`
w-full flex items-center justify-between p-3.5 rounded-lg border-2 transition-all
${severityConfig}
hover:shadow-md cursor-pointer hover:scale-[1.01]
`}
aria-expanded={!isCollapsed}
aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${group.title}`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{isCollapsed ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronUp className="w-5 h-5" />
)}
</div>
<div className="text-left flex-1 min-w-0">
<h3 className="font-bold text-sm truncate">
{group.title}
</h3>
{group.type === 'similarity' && group.count > 1 && (
<p className="text-xs opacity-75 mt-0.5">
{group.alerts.length} alertas similares
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
{group.count > 1 && (
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-white/60 backdrop-blur-sm rounded-lg border border-current/20 min-h-[1.625rem]">
<span className="text-xs font-bold leading-none">{group.count}</span>
<span className="text-xs opacity-75 leading-none">alertas</span>
</div>
)}
{group.severity && (
<Badge variant={badgeVariant} size="sm" className="font-bold px-2.5 py-1 min-h-[1.625rem]">
{group.severity.toUpperCase()}
</Badge>
)}
</div>
</button>
);
};
export default AlertGroupHeader;

View File

@@ -1,118 +0,0 @@
import React, { useState } from 'react';
import { Clock, X } from 'lucide-react';
import { Button } from '../../ui/Button';
export interface AlertSnoozeMenuProps {
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
onCancel: () => void;
}
const PRESET_DURATIONS = [
{ value: '15min' as const, label: '15 minutos', icon: '⏰' },
{ value: '1hr' as const, label: '1 hora', icon: '🕐' },
{ value: '4hr' as const, label: '4 horas', icon: '🕓' },
{ value: 'tomorrow' as const, label: 'Mañana (9 AM)', icon: '☀️' },
];
const AlertSnoozeMenu: React.FC<AlertSnoozeMenuProps> = ({
onSnooze,
onCancel,
}) => {
const [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState(1);
const handleCustomSnooze = () => {
const milliseconds = customHours * 60 * 60 * 1000;
onSnooze(milliseconds);
};
return (
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg p-3 min-w-[240px]">
{/* Header */}
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[var(--border-primary)]">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-[var(--color-primary)]" />
<span className="text-sm font-semibold text-[var(--text-primary)]">
Posponer hasta
</span>
</div>
<button
onClick={onCancel}
className="p-1 hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
<X className="w-4 h-4 text-[var(--text-secondary)]" />
</button>
</div>
{!showCustom ? (
<>
{/* Preset Options */}
<div className="space-y-1 mb-2">
{PRESET_DURATIONS.map((preset) => (
<button
key={preset.value}
onClick={() => onSnooze(preset.value)}
className="w-full px-3 py-2 text-left text-sm rounded-lg hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2 text-[var(--text-primary)]"
>
<span className="text-lg">{preset.icon}</span>
<span>{preset.label}</span>
</button>
))}
</div>
{/* Custom Option */}
<button
onClick={() => setShowCustom(true)}
className="w-full px-3 py-2 text-left text-sm rounded-lg border border-dashed border-[var(--border-primary)] hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-secondary)]"
>
Personalizado...
</button>
</>
) : (
<>
{/* Custom Time Input */}
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Número de horas
</label>
<input
type="number"
min="1"
max="168"
value={customHours}
onChange={(e) => setCustomHours(parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]"
autoFocus
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Máximo 168 horas (7 días)
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowCustom(false)}
className="flex-1"
>
Atrás
</Button>
<Button
variant="primary"
size="sm"
onClick={handleCustomSnooze}
className="flex-1"
>
Confirmar
</Button>
</div>
</div>
</>
)}
</div>
);
};
export default AlertSnoozeMenu;

View File

@@ -1,179 +0,0 @@
import React from 'react';
import { TrendingUp, Clock, AlertTriangle, BarChart3 } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import type { AlertAnalytics } from '../../../hooks/useAlertAnalytics';
export interface AlertTrendsProps {
analytics: AlertAnalytics | undefined;
className?: string;
}
const AlertTrends: React.FC<AlertTrendsProps> = ({ analytics, className }) => {
// Debug logging
console.log('[AlertTrends] Received analytics:', analytics);
console.log('[AlertTrends] Has trends?', analytics?.trends);
console.log('[AlertTrends] Is array?', Array.isArray(analytics?.trends));
// Safety check: handle undefined or missing analytics data
if (!analytics || !analytics.trends || !Array.isArray(analytics.trends)) {
console.log('[AlertTrends] Showing loading state');
return (
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)]">
Cargando analíticas...
</div>
</div>
);
}
console.log('[AlertTrends] Rendering analytics with', analytics.trends.length, 'trends');
// Ensure we have valid trend data
const validTrends = analytics.trends.filter(t => t && typeof t.count === 'number');
const maxCount = validTrends.length > 0 ? Math.max(...validTrends.map(t => t.count), 1) : 1;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Hoy';
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Ayer';
}
return date.toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' });
};
return (
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[var(--color-primary)]" />
<h3 className="font-semibold text-[var(--text-primary)]">
Tendencias (7 días)
</h3>
</div>
<Badge variant="secondary" size="sm">
{analytics.totalAlerts} total
</Badge>
</div>
{/* Chart */}
<div className="mb-4">
<div className="flex items-end justify-between gap-1 h-32">
{analytics.trends.map((trend, index) => {
const heightPercentage = maxCount > 0 ? (trend.count / maxCount) * 100 : 0;
return (
<div
key={trend.date}
className="flex-1 flex flex-col items-center gap-1"
>
<div className="w-full flex flex-col justify-end" style={{ height: '100px' }}>
{/* Bar */}
<div
className="w-full bg-gradient-to-t from-[var(--color-primary)] to-[var(--color-primary)]/60 rounded-t transition-all hover:opacity-80 cursor-pointer relative group"
style={{ height: `${heightPercentage}%`, minHeight: trend.count > 0 ? '4px' : '0' }}
title={`${trend.count} alertas`}
>
{/* Tooltip on hover */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<div className="bg-[var(--text-primary)] text-white text-xs rounded px-2 py-1 whitespace-nowrap">
{trend.count} alertas
<div className="text-[10px] opacity-75">
🔴 {trend.urgentCount} 🟠 {trend.highCount} 🔵 {trend.mediumCount} 🟢 {trend.lowCount}
</div>
</div>
</div>
</div>
</div>
{/* Label */}
<span className="text-[10px] text-[var(--text-secondary)] text-center">
{formatDate(trend.date)}
</span>
</div>
);
})}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-[var(--border-primary)]">
{/* Average Response Time */}
<div className="flex items-center gap-2">
<div className="p-2 bg-blue-500/10 rounded">
<Clock className="w-4 h-4 text-blue-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Respuesta promedio</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.averageResponseTime > 0 ? `${analytics.averageResponseTime} min` : 'N/A'}
</div>
</div>
</div>
{/* Daily Average */}
<div className="flex items-center gap-2">
<div className="p-2 bg-purple-500/10 rounded">
<TrendingUp className="w-4 h-4 text-purple-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Promedio diario</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.predictedDailyAverage} alertas
</div>
</div>
</div>
{/* Resolution Rate */}
<div className="flex items-center gap-2">
<div className="p-2 bg-green-500/10 rounded">
<BarChart3 className="w-4 h-4 text-green-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Tasa de resolución</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.resolutionRate}%
</div>
</div>
</div>
{/* Busiest Day */}
<div className="flex items-center gap-2">
<div className="p-2 bg-orange-500/10 rounded">
<AlertTriangle className="w-4 h-4 text-orange-500" />
</div>
<div>
<div className="text-xs text-[var(--text-secondary)]">Día más activo</div>
<div className="text-sm font-semibold text-[var(--text-primary)]">
{analytics.busiestDay}
</div>
</div>
</div>
</div>
{/* Top Categories */}
{analytics.topCategories.length > 0 && (
<div className="mt-3 pt-3 border-t border-[var(--border-primary)]">
<div className="text-xs font-semibold text-[var(--text-secondary)] mb-2">
Categorías principales
</div>
<div className="flex flex-wrap gap-2">
{analytics.topCategories.map((cat) => (
<Badge key={cat.category} variant="secondary" size="sm">
{cat.count} ({cat.percentage}%)
</Badge>
))}
</div>
</div>
)}
</div>
);
};
export default AlertTrends;

View File

@@ -0,0 +1,286 @@
// ================================================================
// frontend/src/components/domain/dashboard/AutoActionCountdownComponent.tsx
// ================================================================
/**
* Auto Action Countdown Component
*
* Displays countdown timer for ESCALATION type alerts where AI will
* automatically take action unless user intervenes.
*
* Design Philosophy:
* - High urgency visual design
* - Clear countdown timer
* - One-click cancel button
* - Shows what action will be taken
*/
import React, { useState, useEffect } from 'react';
import { AlertTriangle, Clock, XCircle, CheckCircle } from 'lucide-react';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import { useTranslation } from 'react-i18next';
export interface AutoActionCountdownProps {
actionDescription: string;
countdownSeconds: number;
onCancel: () => Promise<void>;
financialImpactEur?: number;
alertId: string;
className?: string;
}
export function AutoActionCountdownComponent({
actionDescription,
countdownSeconds,
onCancel,
financialImpactEur,
alertId,
className = '',
}: AutoActionCountdownProps) {
const { t } = useTranslation('alerts');
const [timeRemaining, setTimeRemaining] = useState(countdownSeconds);
const [isCancelling, setIsCancelling] = useState(false);
const [isCancelled, setIsCancelled] = useState(false);
useEffect(() => {
if (timeRemaining <= 0 || isCancelled) return;
const timer = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [timeRemaining, isCancelled]);
const handleCancel = async () => {
setIsCancelling(true);
try {
await onCancel();
setIsCancelled(true);
} catch (error) {
console.error('Failed to cancel auto-action:', error);
} finally {
setIsCancelling(false);
}
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const getUrgencyLevel = (seconds: number): 'critical' | 'warning' | 'info' => {
if (seconds <= 60) return 'critical';
if (seconds <= 300) return 'warning';
return 'info';
};
const urgency = getUrgencyLevel(timeRemaining);
const progressPercentage = ((countdownSeconds - timeRemaining) / countdownSeconds) * 100;
// Cancelled state
if (isCancelled) {
return (
<div
className={`rounded-lg p-4 border ${className}`}
style={{
backgroundColor: 'var(--color-success-50)',
borderColor: 'var(--color-success-300)',
}}
>
<div className="flex items-center gap-3">
<CheckCircle className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--color-success-600)' }} />
<div className="flex-1">
<h4 className="font-semibold" style={{ color: 'var(--color-success-700)' }}>
{t('auto_action.cancelled_title', 'Auto-action Cancelled')}
</h4>
<p className="text-sm mt-1" style={{ color: 'var(--color-success-600)' }}>
{t('auto_action.cancelled_message', 'The automatic action has been prevented. You can now handle this manually.')}
</p>
</div>
</div>
</div>
);
}
// Action completed (countdown reached 0)
if (timeRemaining <= 0) {
return (
<div
className={`rounded-lg p-4 border ${className}`}
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
}}
>
<div className="flex items-center gap-3">
<Clock className="w-6 h-6 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1">
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('auto_action.completed_title', 'Auto-action Executed')}
</h4>
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{actionDescription}
</p>
</div>
</div>
</div>
);
}
// Active countdown
return (
<div
className={`rounded-lg p-4 border-2 shadow-lg ${className}`}
style={{
backgroundColor:
urgency === 'critical'
? 'var(--color-error-50)'
: urgency === 'warning'
? 'var(--color-warning-50)'
: 'var(--color-info-50)',
borderColor:
urgency === 'critical'
? 'var(--color-error-400)'
: urgency === 'warning'
? 'var(--color-warning-400)'
: 'var(--color-info-400)',
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 flex-1">
<div
className={`p-2 rounded-full ${
urgency === 'critical' ? 'animate-pulse' : ''
}`}
style={{
backgroundColor:
urgency === 'critical'
? 'var(--color-error-100)'
: urgency === 'warning'
? 'var(--color-warning-100)'
: 'var(--color-info-100)',
}}
>
<AlertTriangle
className="w-5 h-5"
style={{
color:
urgency === 'critical'
? 'var(--color-error-600)'
: urgency === 'warning'
? 'var(--color-warning-600)'
: 'var(--color-info-600)',
}}
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold" style={{ color: 'var(--text-primary)' }}>
{t('auto_action.title', 'Auto-action Pending')}
</h4>
<Badge
variant={urgency === 'critical' ? 'error' : urgency === 'warning' ? 'warning' : 'info'}
size="sm"
>
{urgency === 'critical'
? t('auto_action.urgency.critical', 'URGENT')
: urgency === 'warning'
? t('auto_action.urgency.warning', 'SOON')
: t('auto_action.urgency.info', 'SCHEDULED')}
</Badge>
</div>
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{actionDescription}
</p>
</div>
</div>
{/* Countdown Timer */}
<div className="text-center flex-shrink-0">
<div
className="text-2xl font-bold tabular-nums"
style={{
color:
urgency === 'critical'
? 'var(--color-error-600)'
: urgency === 'warning'
? 'var(--color-warning-600)'
: 'var(--color-info-600)',
}}
>
{formatTime(timeRemaining)}
</div>
<div className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
{t('auto_action.remaining', 'remaining')}
</div>
</div>
</div>
{/* Progress Bar */}
<div className="mb-3 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--bg-tertiary)', height: '6px' }}>
<div
className="h-full transition-all duration-1000 ease-linear"
style={{
width: `${progressPercentage}%`,
backgroundColor:
urgency === 'critical'
? 'var(--color-error-500)'
: urgency === 'warning'
? 'var(--color-warning-500)'
: 'var(--color-info-500)',
}}
/>
</div>
{/* Financial Impact & Cancel Button */}
<div className="flex items-center justify-between gap-3">
{financialImpactEur !== undefined && financialImpactEur > 0 && (
<div className="text-sm">
<span className="font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('auto_action.financial_impact', 'Impact:')}
</span>{' '}
<span className="font-bold" style={{ color: 'var(--text-primary)' }}>
{financialImpactEur.toFixed(2)}
</span>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
className="ml-auto bg-white/80 hover:bg-white flex items-center gap-2"
>
<XCircle className="w-4 h-4" />
{isCancelling
? t('auto_action.cancelling', 'Cancelling...')
: t('auto_action.cancel_button', 'Cancel Auto-action')}
</Button>
</div>
{/* Help Text */}
<div
className="mt-3 text-xs p-2 rounded"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-tertiary)',
}}
>
{t(
'auto_action.help_text',
'AI will automatically execute this action when the timer expires. Click "Cancel" to prevent it and handle manually.'
)}
</div>
</div>
);
}

View File

@@ -1,298 +0,0 @@
import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useIngredients } from '../../../api/hooks/inventory';
import { useSuppliers } from '../../../api/hooks/suppliers';
import { useRecipes } from '../../../api/hooks/recipes';
import { useQualityTemplates } from '../../../api/hooks/qualityTemplates';
import { CheckCircle2, Circle, AlertCircle, ChevronRight, Package, Users, BookOpen, Shield } from 'lucide-react';
interface ConfigurationSection {
id: string;
title: string;
icon: React.ElementType;
path: string;
count: number;
minimum: number;
recommended: number;
isOptional?: boolean;
isComplete: boolean;
nextAction?: string;
}
export const ConfigurationProgressWidget: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// Fetch configuration data
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(tenantId, {}, { enabled: !!tenantId });
const { data: suppliersData, isLoading: loadingSuppliers } = useSuppliers(tenantId, { enabled: !!tenantId });
const suppliers = suppliersData?.suppliers || [];
const { data: recipesData, isLoading: loadingRecipes } = useRecipes(tenantId, { enabled: !!tenantId });
const recipes = recipesData?.recipes || [];
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(tenantId, { enabled: !!tenantId });
const qualityTemplates = qualityData?.templates || [];
const isLoading = loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality;
// Calculate configuration sections
const sections: ConfigurationSection[] = useMemo(() => [
{
id: 'inventory',
title: t('dashboard:config.inventory', 'Inventory'),
icon: Package,
path: '/app/operations/inventory',
count: ingredients.length,
minimum: 3,
recommended: 10,
isComplete: ingredients.length >= 3,
nextAction: ingredients.length < 3 ? t('dashboard:config.add_ingredients', 'Add at least {{count}} ingredients', { count: 3 - ingredients.length }) : undefined
},
{
id: 'suppliers',
title: t('dashboard:config.suppliers', 'Suppliers'),
icon: Users,
path: '/app/operations/suppliers',
count: suppliers.length,
minimum: 1,
recommended: 3,
isComplete: suppliers.length >= 1,
nextAction: suppliers.length < 1 ? t('dashboard:config.add_supplier', 'Add your first supplier') : undefined
},
{
id: 'recipes',
title: t('dashboard:config.recipes', 'Recipes'),
icon: BookOpen,
path: '/app/operations/recipes',
count: recipes.length,
minimum: 1,
recommended: 3,
isComplete: recipes.length >= 1,
nextAction: recipes.length < 1 ? t('dashboard:config.add_recipe', 'Create your first recipe') : undefined
},
{
id: 'quality',
title: t('dashboard:config.quality', 'Quality Standards'),
icon: Shield,
path: '/app/operations/production/quality',
count: qualityTemplates.length,
minimum: 0,
recommended: 2,
isOptional: true,
isComplete: true, // Optional, so always "complete"
nextAction: qualityTemplates.length < 2 ? t('dashboard:config.add_quality', 'Add quality checks (optional)') : undefined
}
], [ingredients.length, suppliers.length, recipes.length, qualityTemplates.length, t]);
// Calculate overall progress
const { completedSections, totalSections, progressPercentage, nextIncompleteSection } = useMemo(() => {
const requiredSections = sections.filter(s => !s.isOptional);
const completed = requiredSections.filter(s => s.isComplete).length;
const total = requiredSections.length;
const percentage = Math.round((completed / total) * 100);
const nextIncomplete = sections.find(s => !s.isComplete && !s.isOptional);
return {
completedSections: completed,
totalSections: total,
progressPercentage: percentage,
nextIncompleteSection: nextIncomplete
};
}, [sections]);
const isFullyConfigured = progressPercentage === 100;
// Determine unlocked features
const unlockedFeatures = useMemo(() => {
const features: string[] = [];
if (ingredients.length >= 3) features.push(t('dashboard:config.features.inventory_tracking', 'Inventory Tracking'));
if (suppliers.length >= 1 && ingredients.length >= 3) features.push(t('dashboard:config.features.purchase_orders', 'Purchase Orders'));
if (recipes.length >= 1 && ingredients.length >= 3) features.push(t('dashboard:config.features.production_planning', 'Production Planning'));
if (recipes.length >= 1 && ingredients.length >= 3 && suppliers.length >= 1) features.push(t('dashboard:config.features.cost_analysis', 'Cost Analysis'));
return features;
}, [ingredients.length, suppliers.length, recipes.length, t]);
if (isLoading) {
return (
<div className="bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg p-6">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-primary)]"></div>
<span className="text-sm text-[var(--text-secondary)]">{t('common:loading', 'Loading configuration...')}</span>
</div>
</div>
);
}
// Don't show widget if fully configured
if (isFullyConfigured) {
return null;
}
return (
<div className="bg-gradient-to-br from-[var(--bg-primary)] to-[var(--bg-secondary)] border-2 border-[var(--color-primary)]/20 rounded-xl shadow-lg overflow-hidden">
{/* Header */}
<div className="p-6 pb-4 border-b border-[var(--border-secondary)]">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
🏗 {t('dashboard:config.title', 'Complete Your Bakery Setup')}
</h3>
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
{t('dashboard:config.subtitle', 'Configure essential features to get started')}
</p>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium text-[var(--text-primary)]">
{completedSections}/{totalSections} {t('dashboard:config.sections_complete', 'sections complete')}
</span>
<span className="text-[var(--color-primary)] font-bold">{progressPercentage}%</span>
</div>
<div className="w-full h-2.5 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-success)] transition-all duration-500 ease-out"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
</div>
{/* Sections List */}
<div className="p-6 pt-4 space-y-3">
{sections.map((section) => {
const Icon = section.icon;
const meetsRecommended = section.count >= section.recommended;
return (
<button
key={section.id}
onClick={() => navigate(section.path)}
className={`w-full p-4 rounded-lg border-2 transition-all duration-200 text-left group ${
section.isComplete
? 'border-[var(--color-success)]/30 bg-[var(--color-success)]/5 hover:bg-[var(--color-success)]/10'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)]'
}`}
>
<div className="flex items-center gap-4">
{/* Status Icon */}
<div className={`flex-shrink-0 ${
section.isComplete
? 'text-[var(--color-success)]'
: 'text-[var(--text-tertiary)]'
}`}>
{section.isComplete ? (
<CheckCircle2 className="w-5 h-5" />
) : (
<Circle className="w-5 h-5" />
)}
</div>
{/* Section Icon */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
section.isComplete
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
}`}>
<Icon className="w-5 h-5" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-[var(--text-primary)]">{section.title}</h4>
{section.isOptional && (
<span className="text-xs px-2 py-0.5 bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] rounded-full">
{t('common:optional', 'Optional')}
</span>
)}
</div>
<div className="flex items-center gap-3 text-sm">
<span className={`font-medium ${
section.isComplete
? 'text-[var(--color-success)]'
: 'text-[var(--text-secondary)]'
}`}>
{section.count} {t('dashboard:config.added', 'added')}
</span>
{!section.isComplete && section.nextAction && (
<span className="text-[var(--text-tertiary)]">
{section.nextAction}
</span>
)}
{section.isComplete && !meetsRecommended && (
<span className="text-[var(--text-tertiary)]">
{section.recommended} {t('dashboard:config.recommended', 'recommended')}
</span>
)}
</div>
</div>
{/* Chevron */}
<ChevronRight className="w-5 h-5 text-[var(--text-tertiary)] group-hover:text-[var(--color-primary)] transition-colors flex-shrink-0" />
</div>
</button>
);
})}
</div>
{/* Next Action / Unlocked Features */}
<div className="px-6 pb-6">
{nextIncompleteSection ? (
<div className="p-4 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/30 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-[var(--text-primary)] mb-1">
👉 {t('dashboard:config.next_step', 'Next Step')}
</p>
<p className="text-sm text-[var(--text-secondary)] mb-3">
{nextIncompleteSection.nextAction}
</p>
<button
onClick={() => navigate(nextIncompleteSection.path)}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors text-sm font-medium inline-flex items-center gap-2"
>
{t('dashboard:config.configure', 'Configure')} {nextIncompleteSection.title}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
) : unlockedFeatures.length > 0 && (
<div className="p-4 bg-[var(--color-success)]/10 border border-[var(--color-success)]/30 rounded-lg">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-[var(--color-success)] flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-[var(--text-primary)] mb-2">
🎉 {t('dashboard:config.features_unlocked', 'Features Unlocked!')}
</p>
<ul className="space-y-1">
{unlockedFeatures.map((feature, idx) => (
<li key={idx} className="text-sm text-[var(--text-secondary)] flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-[var(--color-success)]" />
{feature}
</li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { cn } from '../../../lib/utils';
interface PriorityBadgeProps {
score: number; // 0-100
level: 'critical' | 'important' | 'standard' | 'info';
className?: string;
showScore?: boolean;
}
/**
* PriorityBadge - Visual indicator for alert priority
*
* Priority Levels:
* - Critical (90-100): Red with pulse animation
* - Important (70-89): Orange/Yellow
* - Standard (50-69): Blue
* - Info (0-49): Gray
*/
export const PriorityBadge: React.FC<PriorityBadgeProps> = ({
score,
level,
className,
showScore = true,
}) => {
const getStyles = () => {
switch (level) {
case 'critical':
return {
bg: 'bg-red-100 dark:bg-red-900/30',
text: 'text-red-800 dark:text-red-200',
border: 'border-red-300 dark:border-red-700',
pulse: 'animate-pulse',
};
case 'important':
return {
bg: 'bg-orange-100 dark:bg-orange-900/30',
text: 'text-orange-800 dark:text-orange-200',
border: 'border-orange-300 dark:border-orange-700',
pulse: '',
};
case 'standard':
return {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-300 dark:border-blue-700',
pulse: '',
};
case 'info':
default:
return {
bg: 'bg-gray-100 dark:bg-gray-800',
text: 'text-gray-700 dark:text-gray-300',
border: 'border-gray-300 dark:border-gray-600',
pulse: '',
};
}
};
const styles = getStyles();
const displayText = level.charAt(0).toUpperCase() + level.slice(1);
return (
<div
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border',
styles.bg,
styles.text,
styles.border,
styles.pulse,
className
)}
title={`Puntuación de prioridad: ${score}/100`}
>
<span>{displayText}</span>
{showScore && (
<span className="font-bold">{score}</span>
)}
</div>
);
};
export default PriorityBadge;

View File

@@ -0,0 +1,358 @@
// ================================================================
// frontend/src/components/domain/dashboard/PriorityScoreExplainerModal.tsx
// ================================================================
/**
* Priority Score Explainer Modal
*
* Educational modal explaining the 0-100 priority scoring algorithm.
* Builds trust by showing transparency into AI decision-making.
*
* Shows:
* - Overall priority score breakdown
* - 4 weighted components (Business Impact 40%, Urgency 30%, Agency 20%, Confidence 10%)
* - Example calculations
* - Visual progress bars for each component
*/
import React from 'react';
import { X, TrendingUp, DollarSign, Clock, Target, Brain, Info } from 'lucide-react';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import { useTranslation } from 'react-i18next';
export interface PriorityScoreExplainerModalProps {
isOpen: boolean;
onClose: () => void;
exampleScore?: number;
exampleBreakdown?: {
businessImpact: number;
urgency: number;
agency: number;
confidence: number;
};
}
interface ScoreComponent {
name: string;
icon: typeof DollarSign;
weight: number;
description: string;
examples: string[];
color: string;
}
export function PriorityScoreExplainerModal({
isOpen,
onClose,
exampleScore,
exampleBreakdown,
}: PriorityScoreExplainerModalProps) {
const { t } = useTranslation('alerts');
if (!isOpen) return null;
const components: ScoreComponent[] = [
{
name: t('priority_explainer.business_impact.name', 'Business Impact'),
icon: DollarSign,
weight: 40,
description: t(
'priority_explainer.business_impact.description',
'Financial consequences, affected orders, customer satisfaction'
),
examples: [
t('priority_explainer.business_impact.example1', '€500 in potential revenue at risk'),
t('priority_explainer.business_impact.example2', '10 customer orders affected'),
t('priority_explainer.business_impact.example3', 'High customer satisfaction impact'),
],
color: 'var(--color-error-500)',
},
{
name: t('priority_explainer.urgency.name', 'Urgency'),
icon: Clock,
weight: 30,
description: t(
'priority_explainer.urgency.description',
'Time sensitivity, deadlines, escalation potential'
),
examples: [
t('priority_explainer.urgency.example1', 'Deadline in 2 hours'),
t('priority_explainer.urgency.example2', 'Stockout imminent (4 hours)'),
t('priority_explainer.urgency.example3', 'Production window closing soon'),
],
color: 'var(--color-warning-500)',
},
{
name: t('priority_explainer.agency.name', 'User Agency'),
icon: Target,
weight: 20,
description: t(
'priority_explainer.agency.description',
'Can you take action? Do you have control over the outcome?'
),
examples: [
t('priority_explainer.agency.example1', 'Requires approval from you'),
t('priority_explainer.agency.example2', 'One-click action available'),
t('priority_explainer.agency.example3', 'Decision needed within your authority'),
],
color: 'var(--color-primary-500)',
},
{
name: t('priority_explainer.confidence.name', 'AI Confidence'),
icon: Brain,
weight: 10,
description: t(
'priority_explainer.confidence.description',
"How certain is the AI about this alert's validity?"
),
examples: [
t('priority_explainer.confidence.example1', 'Based on historical patterns (95% match)'),
t('priority_explainer.confidence.example2', 'Data quality: High'),
t('priority_explainer.confidence.example3', 'Prediction accuracy validated'),
],
color: 'var(--color-info-500)',
},
];
const calculateExampleScore = (breakdown?: typeof exampleBreakdown): number => {
if (!breakdown) return 75; // Default example
return (
(breakdown.businessImpact * 0.4) +
(breakdown.urgency * 0.3) +
(breakdown.agency * 0.2) +
(breakdown.confidence * 0.1)
);
};
const displayScore = exampleScore ?? calculateExampleScore(exampleBreakdown);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
onClick={onClose}
>
<div
className="bg-[var(--bg-primary)] rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="sticky top-0 z-10 flex items-center justify-between p-6 border-b"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
<div className="flex items-center gap-3">
<div
className="p-2 rounded-lg"
style={{ backgroundColor: 'var(--color-primary-100)' }}
>
<TrendingUp className="w-6 h-6" style={{ color: 'var(--color-primary-600)' }} />
</div>
<div>
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{t('priority_explainer.title', 'Understanding Priority Scores')}
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{t('priority_explainer.subtitle', 'How AI calculates what needs your attention first')}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Overview */}
<div
className="rounded-lg p-5 border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
}}
>
<div className="flex items-start gap-3">
<Info className="w-5 h-5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-info)' }} />
<div>
<h3 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('priority_explainer.overview.title', 'The Priority Score (0-100)')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t(
'priority_explainer.overview.description',
'Every alert receives a priority score from 0-100 based on four weighted components. This helps you focus on what truly matters for your bakery.'
)}
</p>
</div>
</div>
</div>
{/* Example Score */}
{exampleScore !== undefined && (
<div
className="rounded-lg p-5 border-2"
style={{
backgroundColor: 'var(--color-primary-50)',
borderColor: 'var(--color-primary-300)',
}}
>
<div className="text-center">
<div className="text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{t('priority_explainer.example_alert', 'Example Alert Priority')}
</div>
<div className="flex items-center justify-center gap-3">
<div className="text-5xl font-bold" style={{ color: 'var(--color-primary-600)' }}>
{displayScore.toFixed(0)}
</div>
<Badge variant="primary" size="lg">
{displayScore >= 90
? t('priority_explainer.level.critical', 'CRITICAL')
: displayScore >= 70
? t('priority_explainer.level.important', 'IMPORTANT')
: displayScore >= 50
? t('priority_explainer.level.standard', 'STANDARD')
: t('priority_explainer.level.info', 'INFO')}
</Badge>
</div>
</div>
</div>
)}
{/* Components Breakdown */}
<div>
<h3 className="text-lg font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
{t('priority_explainer.components_title', 'Score Components')}
</h3>
<div className="space-y-4">
{components.map((component) => {
const Icon = component.icon;
const componentValue = exampleBreakdown
? exampleBreakdown[
component.name.toLowerCase().replace(/\s+/g, '') as keyof typeof exampleBreakdown
] || 75
: 75;
return (
<div
key={component.name}
className="rounded-lg p-4 border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Component Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div
className="p-2 rounded-lg"
style={{ backgroundColor: component.color + '20' }}
>
<Icon className="w-5 h-5" style={{ color: component.color }} />
</div>
<div>
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{component.name}
</h4>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{component.description}
</p>
</div>
</div>
<Badge variant="secondary" size="sm">
{component.weight}% {t('priority_explainer.weight', 'weight')}
</Badge>
</div>
{/* Progress Bar */}
<div className="mb-3">
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<div
className="h-full transition-all duration-500"
style={{
width: `${componentValue}%`,
backgroundColor: component.color,
}}
/>
</div>
<div className="flex justify-between text-xs mt-1">
<span style={{ color: 'var(--text-tertiary)' }}>0</span>
<span className="font-semibold" style={{ color: component.color }}>
{componentValue}/100
</span>
<span style={{ color: 'var(--text-tertiary)' }}>100</span>
</div>
</div>
{/* Examples */}
<div className="space-y-1">
{component.examples.map((example, idx) => (
<div key={idx} className="flex items-start gap-2 text-xs">
<span style={{ color: component.color }}></span>
<span style={{ color: 'var(--text-secondary)' }}>{example}</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
{/* Formula */}
<div
className="rounded-lg p-5 border"
style={{
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border-secondary)',
}}
>
<h4 className="font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
{t('priority_explainer.formula_title', 'The Formula')}
</h4>
<div
className="font-mono text-sm p-3 rounded"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-primary)',
}}
>
Priority Score = (Business Impact × 0.40) + (Urgency × 0.30) + (User Agency × 0.20) + (AI Confidence ×
0.10)
</div>
</div>
</div>
{/* Footer */}
<div
className="sticky bottom-0 flex items-center justify-between p-6 border-t"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t(
'priority_explainer.footer',
'This scoring helps AI prioritize alerts, ensuring you see the most important issues first.'
)}
</p>
<Button onClick={onClose} variant="primary">
{t('priority_explainer.got_it', 'Got it!')}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,539 +0,0 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { SeverityBadge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import { useNotifications } from '../../../hooks/useNotifications';
import { useAlertFilters } from '../../../hooks/useAlertFilters';
import { useAlertGrouping, type GroupingMode } from '../../../hooks/useAlertGrouping';
import { useAlertAnalytics, useAlertAnalyticsTracking } from '../../../hooks/useAlertAnalytics';
import { useKeyboardNavigation } from '../../../hooks/useKeyboardNavigation';
import { filterAlerts, getAlertStatistics, getTimeGroup } from '../../../utils/alertHelpers';
import {
Bell,
Wifi,
WifiOff,
CheckCircle,
BarChart3,
AlertTriangle,
AlertCircle,
Clock,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import AlertFilters from './AlertFilters';
import AlertGroupHeader from './AlertGroupHeader';
import AlertCard from './AlertCard';
import AlertTrends from './AlertTrends';
import AlertBulkActions from './AlertBulkActions';
export interface RealTimeAlertsProps {
className?: string;
maxAlerts?: number;
showAnalytics?: boolean;
showGrouping?: boolean;
}
/**
* RealTimeAlerts - Dashboard component for displaying today's active alerts
*
* IMPORTANT: This component shows ONLY TODAY'S alerts (from 00:00 UTC today onwards)
* to prevent flooding the dashboard with historical data.
*
* For historical alert data, use the Analytics panel or API endpoints:
* - showAnalytics=true: Shows AlertTrends component with historical data (7 days, 30 days, etc.)
* - API: /api/v1/tenants/{tenant_id}/alerts/analytics for historical analytics
*
* Alert scopes across the application:
* - Dashboard (this component): TODAY'S alerts only
* - Notification Bell: Last 24 hours
* - Analytics Panel: Historical data (configurable: 7 days, 30 days, etc.)
* - localStorage: Auto-cleanup of alerts >24h old on load
* - Redis cache (initial_items): TODAY'S alerts only
*/
const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
className,
maxAlerts = 50,
showAnalytics = true,
showGrouping = true,
}) => {
const { t } = useTranslation(['dashboard']);
const [expandedAlerts, setExpandedAlerts] = useState<Set<string>>(new Set());
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
const [showBulkActions, setShowBulkActions] = useState(false);
const [showAnalyticsPanel, setShowAnalyticsPanel] = useState(false);
// Pagination state
const ALERTS_PER_PAGE = 3;
const [currentPage, setCurrentPage] = useState(1);
const {
notifications,
isConnected,
markAsRead,
removeNotification,
snoozeAlert,
unsnoozeAlert,
isAlertSnoozed,
snoozedAlerts,
markMultipleAsRead,
removeMultiple,
snoozeMultiple,
} = useNotifications();
const {
filters,
toggleSeverity,
toggleCategory,
setTimeRange,
setSearch,
toggleShowSnoozed,
clearFilters,
hasActiveFilters,
activeFilterCount,
} = useAlertFilters();
// Dashboard shows only TODAY's alerts
// Analytics panel shows historical data (configured separately)
const filteredNotifications = useMemo(() => {
// Filter to today's alerts only for dashboard display
// This prevents showing yesterday's or older alerts on the main dashboard
const todayAlerts = notifications.filter(alert => {
const timeGroup = getTimeGroup(alert.timestamp);
return timeGroup === 'today';
});
return filterAlerts(todayAlerts, filters, snoozedAlerts).slice(0, maxAlerts);
}, [notifications, filters, snoozedAlerts, maxAlerts]);
const {
groupedAlerts,
groupingMode,
setGroupingMode,
toggleGroupCollapse,
isGroupCollapsed,
} = useAlertGrouping(filteredNotifications, 'time');
const analytics = useAlertAnalytics(notifications);
const { trackAcknowledgment, trackResolution } = useAlertAnalyticsTracking();
const stats = useMemo(() => {
return getAlertStatistics(filteredNotifications, snoozedAlerts);
}, [filteredNotifications, snoozedAlerts]);
const flatAlerts = useMemo(() => {
return groupedAlerts.flatMap(group =>
isGroupCollapsed(group.id) ? [] : group.alerts
);
}, [groupedAlerts, isGroupCollapsed]);
// Reset pagination when filters change
useEffect(() => {
setCurrentPage(1);
}, [filters, groupingMode]);
// Pagination calculations
const totalAlerts = flatAlerts.length;
const totalPages = Math.ceil(totalAlerts / ALERTS_PER_PAGE);
const startIndex = (currentPage - 1) * ALERTS_PER_PAGE;
const endIndex = startIndex + ALERTS_PER_PAGE;
// Paginated alerts - slice the flat alerts for current page
const paginatedAlerts = useMemo(() => {
const alertsToShow = flatAlerts.slice(startIndex, endIndex);
const alertIds = new Set(alertsToShow.map(a => a.id));
// Filter groups to only show alerts on current page
return groupedAlerts
.map(group => ({
...group,
alerts: group.alerts.filter(alert => alertIds.has(alert.id)),
count: group.alerts.filter(alert => alertIds.has(alert.id)).length,
}))
.filter(group => group.alerts.length > 0);
}, [groupedAlerts, flatAlerts, startIndex, endIndex]);
const { focusedIndex } = useKeyboardNavigation(
flatAlerts.length,
{
onMoveUp: () => {},
onMoveDown: () => {},
onSelect: () => {
if (flatAlerts[focusedIndex]) {
toggleAlertSelection(flatAlerts[focusedIndex].id);
}
},
onExpand: () => {
if (flatAlerts[focusedIndex]) {
toggleAlertExpansion(flatAlerts[focusedIndex].id);
}
},
onMarkAsRead: () => {
if (flatAlerts[focusedIndex]) {
handleMarkAsRead(flatAlerts[focusedIndex].id);
}
},
onDismiss: () => {
if (flatAlerts[focusedIndex]) {
handleRemoveAlert(flatAlerts[focusedIndex].id);
}
},
onSnooze: () => {
if (flatAlerts[focusedIndex]) {
handleSnoozeAlert(flatAlerts[focusedIndex].id, '1hr');
}
},
onEscape: () => {
setExpandedAlerts(new Set());
setSelectedAlerts(new Set());
},
onSelectAll: () => {
handleSelectAll();
},
onSearch: () => {},
},
true
);
const toggleAlertExpansion = useCallback((alertId: string) => {
setExpandedAlerts(prev => {
const next = new Set(prev);
if (next.has(alertId)) {
next.delete(alertId);
} else {
next.add(alertId);
}
return next;
});
}, []);
const toggleAlertSelection = useCallback((alertId: string) => {
setSelectedAlerts(prev => {
const next = new Set(prev);
if (next.has(alertId)) {
next.delete(alertId);
} else {
next.add(alertId);
}
return next;
});
}, []);
const handleMarkAsRead = useCallback((alertId: string) => {
markAsRead(alertId);
trackAcknowledgment(alertId).catch(err =>
console.error('Failed to track acknowledgment:', err)
);
}, [markAsRead, trackAcknowledgment]);
const handleRemoveAlert = useCallback((alertId: string) => {
removeNotification(alertId);
trackResolution(alertId).catch(err =>
console.error('Failed to track resolution:', err)
);
setExpandedAlerts(prev => {
const next = new Set(prev);
next.delete(alertId);
return next;
});
setSelectedAlerts(prev => {
const next = new Set(prev);
next.delete(alertId);
return next;
});
}, [removeNotification, trackResolution]);
const handleSnoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
snoozeAlert(alertId, duration);
}, [snoozeAlert]);
const handleUnsnoozeAlert = useCallback((alertId: string) => {
unsnoozeAlert(alertId);
}, [unsnoozeAlert]);
const handleSelectAll = useCallback(() => {
setSelectedAlerts(new Set(flatAlerts.map(a => a.id)));
setShowBulkActions(true);
}, [flatAlerts]);
const handleDeselectAll = useCallback(() => {
setSelectedAlerts(new Set());
setShowBulkActions(false);
}, []);
const handleBulkMarkAsRead = useCallback(() => {
const ids = Array.from(selectedAlerts);
markMultipleAsRead(ids);
ids.forEach(id =>
trackAcknowledgment(id).catch(err =>
console.error('Failed to track acknowledgment:', err)
)
);
handleDeselectAll();
}, [selectedAlerts, markMultipleAsRead, trackAcknowledgment, handleDeselectAll]);
const handleBulkRemove = useCallback(() => {
const ids = Array.from(selectedAlerts);
removeMultiple(ids);
ids.forEach(id =>
trackResolution(id).catch(err =>
console.error('Failed to track resolution:', err)
)
);
handleDeselectAll();
}, [selectedAlerts, removeMultiple, trackResolution, handleDeselectAll]);
const handleBulkSnooze = useCallback((duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
const ids = Array.from(selectedAlerts);
snoozeMultiple(ids, duration);
handleDeselectAll();
}, [selectedAlerts, snoozeMultiple, handleDeselectAll]);
const activeAlerts = filteredNotifications.filter(a => a.status !== 'acknowledged' && !isAlertSnoozed(a.id));
const urgentCount = activeAlerts.filter(a => a.severity === 'urgent').length;
const highCount = activeAlerts.filter(a => a.severity === 'high').length;
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-start sm:items-center justify-between w-full gap-4 flex-col sm:flex-row">
<div className="flex items-center gap-3">
<div
className="p-2.5 rounded-xl shadow-sm flex-shrink-0"
style={{ backgroundColor: 'var(--color-primary)15' }}
>
<Bell className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
</div>
<div>
<h3 className="text-lg font-bold mb-0.5" style={{ color: 'var(--text-primary)' }}>
{t('dashboard:alerts.title', 'Alertas')}
</h3>
<div className="flex items-center gap-2">
{isConnected ? (
<Wifi className="w-3.5 h-3.5" style={{ color: 'var(--color-success)' }} />
) : (
<WifiOff className="w-3.5 h-3.5" style={{ color: 'var(--color-error)' }} />
)}
<span className="text-xs font-medium whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
{isConnected
? t('dashboard:alerts.live', 'En vivo')
: t('dashboard:alerts.offline', 'Desconectado')
}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
{/* Alert count badges */}
<div className="flex items-center gap-2">
{urgentCount > 0 && (
<SeverityBadge
severity="high"
count={urgentCount}
size="sm"
/>
)}
{highCount > 0 && (
<SeverityBadge
severity="medium"
count={highCount}
size="sm"
/>
)}
</div>
{/* Controls */}
{showAnalytics && (
<Button
variant={showAnalyticsPanel ? 'primary' : 'ghost'}
size="sm"
onClick={() => setShowAnalyticsPanel(!showAnalyticsPanel)}
className="h-9"
title="Toggle analytics"
aria-label="Toggle analytics panel"
>
<BarChart3 className="w-4 h-4" />
</Button>
)}
{showGrouping && (
<select
value={groupingMode}
onChange={(e) => setGroupingMode(e.target.value as GroupingMode)}
className="px-3 py-2 text-sm font-medium border-2 border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all cursor-pointer hover:border-[var(--color-primary)]"
aria-label="Group alerts by"
>
<option value="time"> Por tiempo</option>
<option value="category">📁 Por categoría</option>
<option value="similarity">🔗 Similares</option>
<option value="none">📋 Sin agrupar</option>
</select>
)}
</div>
</div>
</CardHeader>
<CardBody padding="none">
{showAnalyticsPanel && (
<div className="p-4 border-b border-[var(--border-primary)]">
<AlertTrends analytics={analytics} />
</div>
)}
<div className="px-4 py-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/30">
<AlertFilters
selectedSeverities={filters.severities}
selectedCategories={filters.categories}
selectedTimeRange={filters.timeRange}
searchQuery={filters.search}
showSnoozed={filters.showSnoozed}
onToggleSeverity={toggleSeverity}
onToggleCategory={toggleCategory}
onSetTimeRange={setTimeRange}
onSearchChange={setSearch}
onToggleShowSnoozed={toggleShowSnoozed}
onClearFilters={clearFilters}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
/>
</div>
{selectedAlerts.size > 0 && (
<div className="p-4 border-b border-[var(--border-primary)]">
<AlertBulkActions
selectedCount={selectedAlerts.size}
totalCount={flatAlerts.length}
onMarkAsRead={handleBulkMarkAsRead}
onRemove={handleBulkRemove}
onSnooze={handleBulkSnooze}
onDeselectAll={handleDeselectAll}
onSelectAll={handleSelectAll}
/>
</div>
)}
{filteredNotifications.length === 0 ? (
<div className="p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--color-success)]/10 mb-4">
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
</div>
<h4 className="text-base font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{hasActiveFilters ? 'Sin resultados' : 'Todo despejado'}
</h4>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{hasActiveFilters
? 'No hay alertas que coincidan con los filtros seleccionados'
: t('dashboard:alerts.no_alerts', 'No hay alertas activas en este momento')
}
</p>
</div>
) : (
<div className="space-y-3 p-4">
{paginatedAlerts.map((group) => (
<div key={group.id}>
{(group.count > 1 || groupingMode !== 'none') && (
<div className="mb-3">
<AlertGroupHeader
group={group}
isCollapsed={isGroupCollapsed(group.id)}
onToggleCollapse={() => toggleGroupCollapse(group.id)}
/>
</div>
)}
{!isGroupCollapsed(group.id) && (
<div className="space-y-3 ml-0">
{group.alerts.map((alert) => (
<AlertCard
key={alert.id}
alert={alert}
isExpanded={expandedAlerts.has(alert.id)}
isSelected={selectedAlerts.has(alert.id)}
isSnoozed={isAlertSnoozed(alert.id)}
snoozedUntil={snoozedAlerts.get(alert.id)?.until}
onToggleExpand={() => toggleAlertExpansion(alert.id)}
onToggleSelect={() => toggleAlertSelection(alert.id)}
onMarkAsRead={() => handleMarkAsRead(alert.id)}
onRemove={() => handleRemoveAlert(alert.id)}
onSnooze={(duration) => handleSnoozeAlert(alert.id, duration)}
onUnsnooze={() => handleUnsnoozeAlert(alert.id)}
showCheckbox={showBulkActions || selectedAlerts.size > 0}
/>
))}
</div>
)}
</div>
))}
</div>
)}
{filteredNotifications.length > 0 && (
<div
className="px-4 py-3 border-t"
style={{
borderColor: 'var(--border-primary)',
backgroundColor: 'var(--bg-secondary)/50',
}}
>
<div className="flex flex-col gap-3">
{/* Stats row */}
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium">
Mostrando <span className="font-bold text-[var(--text-primary)]">{startIndex + 1}-{Math.min(endIndex, totalAlerts)}</span> de <span className="font-bold text-[var(--text-primary)]">{totalAlerts}</span> alertas
</span>
<div className="flex items-center gap-4">
{stats.unread > 0 && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse" />
<span className="font-semibold text-[var(--text-primary)]">{stats.unread}</span> sin leer
</span>
)}
{stats.snoozed > 0 && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
<span className="font-semibold text-[var(--text-primary)]">{stats.snoozed}</span> pospuestas
</span>
)}
</div>
</div>
{/* Pagination controls */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-8 px-3"
aria-label="Previous page"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium px-3" style={{ color: 'var(--text-primary)' }}>
Página <span className="font-bold">{currentPage}</span> de <span className="font-bold">{totalPages}</span>
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-8 px-3"
aria-label="Next page"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
)}
</CardBody>
</Card>
);
};
export default RealTimeAlerts;

View File

@@ -0,0 +1,284 @@
// ================================================================
// frontend/src/components/domain/dashboard/SmartActionConsequencePreview.tsx
// ================================================================
/**
* Smart Action Consequence Preview
*
* Shows "What happens if I click this?" before user takes action.
* Displays expected outcomes, affected systems, and financial impact.
*
* Design: Tooltip/popover style preview with clear consequences
*/
import React, { useState } from 'react';
import { Info, CheckCircle, AlertTriangle, DollarSign, Clock, ArrowRight } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import { useTranslation } from 'react-i18next';
export interface ActionConsequence {
outcome: string;
affectedSystems?: string[];
financialImpact?: {
amount: number;
currency: string;
type: 'cost' | 'savings' | 'revenue';
};
timeImpact?: string;
reversible: boolean;
confidence?: number;
warnings?: string[];
}
export interface SmartActionConsequencePreviewProps {
action: {
label: string;
actionType: string;
};
consequences: ActionConsequence;
onConfirm: () => void;
onCancel: () => void;
isOpen: boolean;
triggerElement?: React.ReactNode;
}
export function SmartActionConsequencePreview({
action,
consequences,
onConfirm,
onCancel,
isOpen,
triggerElement,
}: SmartActionConsequencePreviewProps) {
const { t } = useTranslation('alerts');
const [showPreview, setShowPreview] = useState(isOpen);
const handleConfirm = () => {
setShowPreview(false);
onConfirm();
};
const handleCancel = () => {
setShowPreview(false);
onCancel();
};
if (!showPreview) {
return (
<div onClick={() => setShowPreview(true)}>
{triggerElement}
</div>
);
}
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm animate-in fade-in duration-200"
onClick={handleCancel}
/>
{/* Preview Card */}
<div
className="fixed z-50 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full max-w-md animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
<div
className="rounded-xl shadow-2xl border-2"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--color-primary-300)',
}}
>
{/* Header */}
<div
className="p-4 border-b"
style={{
backgroundColor: 'var(--color-primary-50)',
borderColor: 'var(--border-primary)',
}}
>
<div className="flex items-center gap-3">
<div
className="p-2 rounded-lg"
style={{ backgroundColor: 'var(--color-primary-100)' }}
>
<Info className="w-5 h-5" style={{ color: 'var(--color-primary-600)' }} />
</div>
<div>
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
{t('action_preview.title', 'Action Preview')}
</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{action.label}
</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Expected Outcome */}
<div>
<div className="flex items-center gap-2 mb-2">
<ArrowRight className="w-4 h-4" style={{ color: 'var(--color-primary)' }} />
<h4 className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{t('action_preview.outcome', 'What will happen')}
</h4>
</div>
<p className="text-sm pl-6" style={{ color: 'var(--text-secondary)' }}>
{consequences.outcome}
</p>
</div>
{/* Financial Impact */}
{consequences.financialImpact && (
<div
className="rounded-lg p-3 border"
style={{
backgroundColor:
consequences.financialImpact.type === 'cost'
? 'var(--color-error-50)'
: 'var(--color-success-50)',
borderColor:
consequences.financialImpact.type === 'cost'
? 'var(--color-error-200)'
: 'var(--color-success-200)',
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign
className="w-4 h-4"
style={{
color:
consequences.financialImpact.type === 'cost'
? 'var(--color-error-600)'
: 'var(--color-success-600)',
}}
/>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{t('action_preview.financial_impact', 'Financial Impact')}
</span>
</div>
<span
className="text-lg font-bold"
style={{
color:
consequences.financialImpact.type === 'cost'
? 'var(--color-error-600)'
: 'var(--color-success-600)',
}}
>
{consequences.financialImpact.type === 'cost' ? '-' : '+'}
{consequences.financialImpact.currency}
{consequences.financialImpact.amount.toFixed(2)}
</span>
</div>
</div>
)}
{/* Affected Systems */}
{consequences.affectedSystems && consequences.affectedSystems.length > 0 && (
<div>
<h4 className="font-semibold text-sm mb-2" style={{ color: 'var(--text-primary)' }}>
{t('action_preview.affected_systems', 'Affected Systems')}
</h4>
<div className="flex flex-wrap gap-2">
{consequences.affectedSystems.map((system, idx) => (
<Badge key={idx} variant="secondary" size="sm">
{system}
</Badge>
))}
</div>
</div>
)}
{/* Time Impact */}
{consequences.timeImpact && (
<div className="flex items-center gap-2 text-sm">
<Clock className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>{consequences.timeImpact}</span>
</div>
)}
{/* Reversibility */}
<div
className="flex items-center gap-2 text-sm p-2 rounded"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<CheckCircle
className="w-4 h-4"
style={{ color: consequences.reversible ? 'var(--color-success-500)' : 'var(--color-warning-500)' }}
/>
<span style={{ color: 'var(--text-secondary)' }}>
{consequences.reversible
? t('action_preview.reversible', 'This action can be undone')
: t('action_preview.not_reversible', 'This action cannot be undone')}
</span>
</div>
{/* Warnings */}
{consequences.warnings && consequences.warnings.length > 0 && (
<div
className="rounded-lg p-3 border"
style={{
backgroundColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-warning-600)' }} />
<div className="space-y-1">
{consequences.warnings.map((warning, idx) => (
<p key={idx} className="text-sm" style={{ color: 'var(--color-warning-700)' }}>
{warning}
</p>
))}
</div>
</div>
</div>
)}
{/* Confidence Score */}
{consequences.confidence !== undefined && (
<div className="text-xs text-center" style={{ color: 'var(--text-tertiary)' }}>
{t('action_preview.confidence', 'AI Confidence: {{confidence}}%', {
confidence: consequences.confidence,
})}
</div>
)}
</div>
{/* Actions */}
<div
className="flex items-center gap-3 p-4 border-t"
style={{ borderColor: 'var(--border-primary)' }}
>
<button
onClick={handleCancel}
className="flex-1 px-4 py-2 rounded-lg font-medium transition-colors"
style={{
backgroundColor: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: '1px solid var(--border-primary)',
}}
>
{t('action_preview.cancel', 'Cancel')}
</button>
<button
onClick={handleConfirm}
className="flex-1 px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: 'var(--color-primary)',
color: 'white',
}}
>
{t('action_preview.confirm', 'Confirm Action')}
</button>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,189 @@
// ================================================================
// frontend/src/components/domain/dashboard/TrendVisualizationComponent.tsx
// ================================================================
/**
* Trend Visualization Component
*
* Displays visual trend indicators for TREND_WARNING type alerts.
* Shows historical comparison with simple sparkline/arrow indicators.
*
* Design: Lightweight, inline trend indicators with color coding
*/
import React from 'react';
import { TrendingUp, TrendingDown, Minus, AlertTriangle } from 'lucide-react';
import { Badge } from '../../ui/Badge';
import { useTranslation } from 'react-i18next';
export interface TrendData {
direction: 'increasing' | 'decreasing' | 'stable';
percentageChange?: number;
historicalComparison?: string;
dataPoints?: number[];
threshold?: number;
current?: number;
}
export interface TrendVisualizationProps {
trend: TrendData;
label?: string;
showSparkline?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function TrendVisualizationComponent({
trend,
label,
showSparkline = false,
size = 'md',
className = '',
}: TrendVisualizationProps) {
const { t } = useTranslation('alerts');
const getTrendIcon = () => {
switch (trend.direction) {
case 'increasing':
return TrendingUp;
case 'decreasing':
return TrendingDown;
case 'stable':
return Minus;
}
};
const getTrendColor = () => {
switch (trend.direction) {
case 'increasing':
return 'var(--color-error-500)';
case 'decreasing':
return 'var(--color-success-500)';
case 'stable':
return 'var(--text-tertiary)';
}
};
const getTrendBadgeVariant = (): 'error' | 'success' | 'secondary' => {
switch (trend.direction) {
case 'increasing':
return 'error';
case 'decreasing':
return 'success';
case 'stable':
return 'secondary';
}
};
const getTrendLabel = () => {
if (trend.percentageChange !== undefined) {
const sign = trend.percentageChange > 0 ? '+' : '';
return `${sign}${trend.percentageChange.toFixed(1)}%`;
}
return trend.direction.charAt(0).toUpperCase() + trend.direction.slice(1);
};
const isNearThreshold = trend.threshold !== undefined && trend.current !== undefined
? Math.abs(trend.current - trend.threshold) / trend.threshold < 0.1
: false;
const TrendIcon = getTrendIcon();
const trendColor = getTrendColor();
const iconSizes = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
return (
<div className={`inline-flex items-center gap-2 ${className}`}>
{/* Trend Indicator */}
<div className="flex items-center gap-1.5">
<TrendIcon
className={iconSizes[size]}
style={{ color: trendColor }}
/>
<Badge variant={getTrendBadgeVariant()} size={size === 'sm' ? 'sm' : 'md'}>
{getTrendLabel()}
</Badge>
</div>
{/* Optional Label */}
{label && (
<span
className={`font-medium ${size === 'sm' ? 'text-xs' : size === 'lg' ? 'text-base' : 'text-sm'}`}
style={{ color: 'var(--text-secondary)' }}
>
{label}
</span>
)}
{/* Threshold Warning */}
{isNearThreshold && (
<div className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3" style={{ color: 'var(--color-warning-500)' }} />
<span className="text-xs font-medium" style={{ color: 'var(--color-warning-600)' }}>
{t('trend.near_threshold', 'Near threshold')}
</span>
</div>
)}
{/* Sparkline (Simple Mini Chart) */}
{showSparkline && trend.dataPoints && trend.dataPoints.length > 0 && (
<div className="flex items-end gap-0.5 h-6">
{trend.dataPoints.slice(-8).map((value, idx) => {
const maxValue = Math.max(...trend.dataPoints!);
const heightPercent = (value / maxValue) * 100;
return (
<div
key={idx}
className="w-1 rounded-t transition-all"
style={{
height: `${heightPercent}%`,
backgroundColor: trendColor,
opacity: 0.3 + (idx / trend.dataPoints!.length) * 0.7,
}}
title={`${value}`}
/>
);
})}
</div>
)}
{/* Historical Comparison Text */}
{trend.historicalComparison && (
<span
className={`${size === 'sm' ? 'text-xs' : 'text-sm'}`}
style={{ color: 'var(--text-tertiary)' }}
>
{trend.historicalComparison}
</span>
)}
</div>
);
}
/**
* Compact version for inline use in alert cards
*/
export function TrendVisualizationInline({ trend }: { trend: TrendData }) {
const TrendIcon = trend.direction === 'increasing' ? TrendingUp
: trend.direction === 'decreasing' ? TrendingDown
: Minus;
const color = trend.direction === 'increasing' ? 'var(--color-error-500)'
: trend.direction === 'decreasing' ? 'var(--color-success-500)'
: 'var(--text-tertiary)';
return (
<div className="inline-flex items-center gap-1">
<TrendIcon className="w-3 h-3" style={{ color }} />
{trend.percentageChange !== undefined && (
<span className="text-xs font-semibold" style={{ color }}>
{trend.percentageChange > 0 ? '+' : ''}{trend.percentageChange.toFixed(1)}%
</span>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo'; import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo';
import { apiClient } from '../../../api/client'; import { apiClient } from '../../../api/client';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -7,7 +7,7 @@ import { BookOpen, Clock, Sparkles, X } from 'lucide-react';
export const DemoBanner: React.FC = () => { export const DemoBanner: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { startTour, resumeTour, tourState } = useDemoTour(); const { startTour, resumeTour } = useDemoTour();
const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true'); const [isDemo, setIsDemo] = useState(() => localStorage.getItem('demo_mode') === 'true');
const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at')); const [expiresAt, setExpiresAt] = useState<string | null>(() => localStorage.getItem('demo_expires_at'));
const [timeRemaining, setTimeRemaining] = useState<string>(''); const [timeRemaining, setTimeRemaining] = useState<string>('');
@@ -15,6 +15,16 @@ export const DemoBanner: React.FC = () => {
const [extending, setExtending] = useState(false); const [extending, setExtending] = useState(false);
const [showExitModal, setShowExitModal] = useState(false); const [showExitModal, setShowExitModal] = useState(false);
// Memoize tour state to prevent re-renders
const tourState = useMemo(() => {
try {
return getTourState();
} catch (error) {
console.error('Error getting tour state:', error);
return null;
}
}, []); // Only get tour state on initial render
useEffect(() => { useEffect(() => {
const demoMode = localStorage.getItem('demo_mode') === 'true'; const demoMode = localStorage.getItem('demo_mode') === 'true';
const expires = localStorage.getItem('demo_expires_at'); const expires = localStorage.getItem('demo_expires_at');

View File

@@ -1,21 +1,15 @@
import React, { useState, useCallback, forwardRef } from 'react'; import React, { forwardRef } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthUser, useIsAuthenticated } from '../../../stores'; import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { useNotifications } from '../../../hooks/useNotifications';
import { useHasAccess } from '../../../hooks/useAccessControl'; import { useHasAccess } from '../../../hooks/useAccessControl';
import { Button } from '../../ui'; import { Button } from '../../ui';
import { CountBadge } from '../../ui';
import { TenantSwitcher } from '../../ui/TenantSwitcher'; import { TenantSwitcher } from '../../ui/TenantSwitcher';
import { ThemeToggle } from '../../ui/ThemeToggle'; import { ThemeToggle } from '../../ui/ThemeToggle';
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
import { CompactLanguageSelector } from '../../ui/LanguageSelector'; import { CompactLanguageSelector } from '../../ui/LanguageSelector';
import { import {
Menu, Menu,
Bell,
X,
TrendingUp TrendingUp
} from 'lucide-react'; } from 'lucide-react';
@@ -33,10 +27,6 @@ export interface HeaderProps {
* Show/hide search functionality * Show/hide search functionality
*/ */
showSearch?: boolean; showSearch?: boolean;
/**
* Show/hide notifications
*/
showNotifications?: boolean;
/** /**
* Show/hide theme toggle * Show/hide theme toggle
*/ */
@@ -49,14 +39,6 @@ export interface HeaderProps {
* Custom search placeholder * Custom search placeholder
*/ */
searchPlaceholder?: string; searchPlaceholder?: string;
/**
* Notification count
*/
notificationCount?: number;
/**
* Custom notification handler
*/
onNotificationClick?: () => void;
} }
export interface HeaderRef { export interface HeaderRef {
@@ -64,12 +46,10 @@ export interface HeaderRef {
} }
/** /**
* Header - Top navigation header with logo, notifications, theme toggle * Header - Top navigation header with logo and theme toggle
* *
* Features: * Features:
* - Logo/brand area with responsive sizing * - Logo/brand area with responsive sizing
* - Global search functionality with keyboard shortcuts
* - Notifications bell with badge count
* - Theme toggle button (light/dark/system) * - Theme toggle button (light/dark/system)
* - Mobile hamburger menu integration * - Mobile hamburger menu integration
* - Keyboard navigation support * - Keyboard navigation support
@@ -79,39 +59,14 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
onMenuClick, onMenuClick,
sidebarCollapsed = false, sidebarCollapsed = false,
showSearch = true, showSearch = true,
showNotifications = true,
showThemeToggle = true, showThemeToggle = true,
logo, logo,
searchPlaceholder, searchPlaceholder,
notificationCount = 0,
onNotificationClick,
}, ref) => { }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthUser(); const user = useAuthUser();
const hasAccess = useHasAccess(); // Check both authentication and demo mode const hasAccess = useHasAccess(); // Check both authentication and demo mode
const { theme, resolvedTheme, setTheme } = useTheme(); const { theme, resolvedTheme, setTheme } = useTheme();
const {
notifications,
unreadCount,
isConnected,
markAsRead,
markAllAsRead,
removeNotification,
clearAll
} = useNotifications();
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
// Filter notifications to last 24 hours for the notification bell
// This prevents showing old/stale alerts in the notification panel
const recentNotifications = React.useMemo(() => {
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
return notifications.filter(n => {
const alertTime = new Date(n.timestamp).getTime();
return alertTime > oneDayAgo;
});
}, [notifications]);
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...'); const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
@@ -121,31 +76,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
}), []); }), []);
// Keyboard shortcuts // Removed notification panel effects as part of new alert system integration
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape to close menus
if (e.key === 'Escape') {
setIsNotificationPanelOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Close menus when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('[data-notification-panel]')) {
setIsNotificationPanelOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
return ( return (
@@ -227,8 +158,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
{/* Right section */} {/* Right section */}
{hasAccess && ( {hasAccess && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Placeholder for potential future items */ }
{/* Language selector */} {/* Language selector */}
<CompactLanguageSelector className="w-auto min-w-[50px]" /> <CompactLanguageSelector className="w-auto min-w-[50px]" />
@@ -236,51 +165,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
{showThemeToggle && ( {showThemeToggle && (
<ThemeToggle variant="button" size="md" /> <ThemeToggle variant="button" size="md" />
)} )}
{/* Notifications */}
{showNotifications && (
<div className="relative" data-notification-panel>
<Button
variant="ghost"
size="sm"
onClick={() => setIsNotificationPanelOpen(!isNotificationPanelOpen)}
className={clsx(
"w-10 h-10 p-0 flex items-center justify-center relative",
!isConnected && "opacity-50",
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
)}
aria-label={`${t('common:navigation.notifications', 'Notifications')}${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ` - ${t('common:status.disconnected', 'Disconnected')}` : ''}`}
title={!isConnected ? t('common:status.no_realtime_connection', 'No real-time connection') : undefined}
aria-expanded={isNotificationPanelOpen}
aria-haspopup="true"
>
<Bell className={clsx(
"h-5 w-5 transition-colors",
unreadCount > 0 && "text-[var(--color-warning)]"
)} />
{unreadCount > 0 && (
<CountBadge
count={unreadCount}
max={99}
variant="error"
size="sm"
overlay
/>
)}
</Button>
<NotificationPanel
notifications={recentNotifications}
isOpen={isNotificationPanelOpen}
onClose={() => setIsNotificationPanelOpen(false)}
onMarkAsRead={markAsRead}
onMarkAllAsRead={markAllAsRead}
onRemoveNotification={removeNotification}
onClearAll={clearAll}
/>
</div>
)}
</div> </div>
)} )}
</header> </header>

View File

@@ -25,6 +25,7 @@ import {
Store, Store,
GraduationCap, GraduationCap,
Bell, Bell,
MessageSquare,
Settings, Settings,
User, User,
CreditCard, CreditCard,
@@ -119,6 +120,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
training: GraduationCap, training: GraduationCap,
notifications: Bell, notifications: Bell,
bell: Bell, bell: Bell,
communications: MessageSquare, // Added for communications section
settings: Settings, settings: Settings,
user: User, user: User,
'credit-card': CreditCard, 'credit-card': CreditCard,

View File

@@ -1,2 +1,2 @@
export { KeyValueEditor } from './KeyValueEditor'; export { KeyValueEditor } from './KeyValueEditor';
export default from './KeyValueEditor'; export default KeyValueEditor;

View File

@@ -7,14 +7,23 @@ import {
Check, Check,
Trash2, Trash2,
AlertTriangle, AlertTriangle,
AlertCircle,
Info,
CheckCircle, CheckCircle,
X X,
Bot,
TrendingUp,
Clock,
DollarSign,
Phone,
ExternalLink
} from 'lucide-react'; } from 'lucide-react';
import { EnrichedAlert, AlertTypeClass } from '../../../types/alerts';
import { useSmartActionHandler } from '../../../utils/smartActionHandlers';
import { getPriorityColor, getTypeClassBadgeVariant, formatTimeUntilConsequence } from '../../../types/alerts';
import { useAuthUser } from '../../../stores/auth.store';
export interface NotificationPanelProps { export interface NotificationPanelProps {
notifications: NotificationData[]; notifications: NotificationData[];
enrichedAlerts?: EnrichedAlert[];
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onMarkAsRead: (id: string) => void; onMarkAsRead: (id: string) => void;
@@ -24,50 +33,7 @@ export interface NotificationPanelProps {
className?: string; className?: string;
} }
const getSeverityIcon = (severity: string) => { // Legacy severity functions removed - now using enriched priority_level and type_class
switch (severity) {
case 'urgent':
return AlertTriangle;
case 'high':
return AlertCircle;
case 'medium':
return Info;
case 'low':
return CheckCircle;
default:
return Info;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'urgent':
return 'var(--color-error)';
case 'high':
return 'var(--color-warning)';
case 'medium':
return 'var(--color-info)';
case 'low':
return 'var(--color-success)';
default:
return 'var(--color-info)';
}
};
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'urgent':
return 'error';
case 'high':
return 'warning';
case 'medium':
return 'info';
case 'low':
return 'success';
default:
return 'info';
}
};
const formatTimestamp = (timestamp: string) => { const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp); const date = new Date(timestamp);
@@ -82,8 +48,176 @@ const formatTimestamp = (timestamp: string) => {
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' }); return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
}; };
// Enriched Alert Item Component
const EnrichedAlertItem: React.FC<{
alert: EnrichedAlert;
isMobile: boolean;
onMarkAsRead: (id: string) => void;
onRemove: (id: string) => void;
actionHandler: any;
}> = ({ alert, isMobile, onMarkAsRead, onRemove, actionHandler }) => {
const isUnread = alert.status === 'active';
const priorityColor = getPriorityColor(alert.priority_level);
return (
<div
className={clsx(
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]",
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3',
isUnread && "bg-[var(--color-info)]/5 border-l-4"
)}
style={isUnread ? { borderLeftColor: priorityColor } : {}}
>
<div className={`flex gap-${isMobile ? '4' : '3'}`}>
{/* Priority Icon */}
<div
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`}
style={{ backgroundColor: priorityColor + '15' }}
>
{alert.type_class === AlertTypeClass.PREVENTED_ISSUE ? (
<CheckCircle className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: 'var(--color-success)' }} />
) : alert.type_class === AlertTypeClass.ESCALATION ? (
<Clock className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: priorityColor }} />
) : (
<AlertTriangle className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} style={{ color: priorityColor }} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className={`flex items-start justify-between gap-2 ${isMobile ? 'mb-2' : 'mb-1'}`}>
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}>
<Badge variant={getTypeClassBadgeVariant(alert.type_class)} size={isMobile ? "md" : "sm"}>
{alert.priority_level.toUpperCase()}
</Badge>
<Badge variant="secondary" size={isMobile ? "md" : "sm"}>
{alert.priority_score}
</Badge>
{alert.is_group_summary && (
<Badge variant="info" size={isMobile ? "md" : "sm"}>
{alert.grouped_alert_count} agrupadas
</Badge>
)}
</div>
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}>
{formatTimestamp(alert.created_at)}
</span>
</div>
{/* Title */}
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
}`}>
{alert.title}
</p>
{/* Message */}
<p className={`leading-relaxed text-[var(--text-secondary)] ${
isMobile ? 'text-sm mb-3' : 'text-xs mb-2'
}`}>
{alert.message}
</p>
{/* Context Badges */}
<div className="flex flex-wrap gap-2 mb-3">
{alert.orchestrator_context?.already_addressed && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-success/10 text-success text-xs">
<Bot className="w-3 h-3" />
<span>AI ya gestionó esto</span>
</div>
)}
{alert.business_impact?.financial_impact_eur && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-warning/10 text-warning text-xs">
<DollarSign className="w-3 h-3" />
<span>{alert.business_impact.financial_impact_eur.toFixed(0)} en riesgo</span>
</div>
)}
{alert.urgency_context?.time_until_consequence_hours && (
<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>
</div>
)}
{alert.trend_context && (
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-info/10 text-info text-xs">
<TrendingUp className="w-3 h-3" />
<span>{alert.trend_context.change_percentage > 0 ? '+' : ''}{alert.trend_context.change_percentage.toFixed(1)}%</span>
</div>
)}
</div>
{/* AI Reasoning Summary */}
{alert.ai_reasoning_summary && (
<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}
</p>
</div>
</div>
)}
{/* Smart Actions */}
{alert.actions && alert.actions.length > 0 && (
<div className={`flex flex-wrap gap-2 ${isMobile ? 'mb-3' : 'mb-2'}`}>
{alert.actions.slice(0, 3).map((action, idx) => (
<Button
key={idx}
size={isMobile ? "sm" : "xs"}
variant={action.variant === 'primary' ? 'default' : 'ghost'}
onClick={() => actionHandler.handleAction(action)}
disabled={action.disabled}
className={`${isMobile ? 'text-xs' : 'text-[10px]'} ${
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.estimated_time_minutes && (
<span className="ml-1 opacity-60">({action.estimated_time_minutes}m)</span>
)}
</Button>
))}
</div>
)}
{/* Standard Actions */}
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
{isUnread && (
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onMarkAsRead(alert.id)}
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
>
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Marcar como leído
</Button>
)}
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onRemove(alert.id)}
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
}`}
>
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Eliminar
</Button>
</div>
</div>
</div>
</div>
);
};
export const NotificationPanel: React.FC<NotificationPanelProps> = ({ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
notifications, notifications,
enrichedAlerts = [],
isOpen, isOpen,
onClose, onClose,
onMarkAsRead, onMarkAsRead,
@@ -94,9 +228,20 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
}) => { }) => {
if (!isOpen) return null; if (!isOpen) return null;
const unreadNotifications = notifications.filter(n => !n.read); const actionHandler = useSmartActionHandler();
const user = useAuthUser();
// Memoize unread notifications to prevent recalculation on every render
const unreadNotifications = React.useMemo(() =>
notifications.filter(n => !n.read),
[notifications]
);
const isMobile = window.innerWidth < 768; const isMobile = window.innerWidth < 768;
// Use enriched alerts if available, otherwise fallback to legacy notifications
const useEnrichedAlerts = enrichedAlerts.length > 0;
return ( return (
<> <>
{/* Backdrop - Only on mobile */} {/* Backdrop - Only on mobile */}
@@ -167,7 +312,7 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
{/* Notifications List */} {/* Notifications List */}
<div className={`flex-1 overflow-y-auto ${isMobile ? 'px-2 py-2' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'px-2 py-2' : ''}`}>
{notifications.length === 0 ? ( {(useEnrichedAlerts ? enrichedAlerts.length === 0 : notifications.length === 0) ? (
<div className={`text-center ${isMobile ? 'py-12 px-6' : 'p-8'}`}> <div className={`text-center ${isMobile ? 'py-12 px-6' : 'p-8'}`}>
<CheckCircle className={`mx-auto mb-3 ${isMobile ? 'w-12 h-12' : 'w-8 h-8'}`} style={{ color: 'var(--color-success)' }} /> <CheckCircle className={`mx-auto mb-3 ${isMobile ? 'w-12 h-12' : 'w-8 h-8'}`} style={{ color: 'var(--color-success)' }} />
<p className={`text-[var(--text-secondary)] ${isMobile ? 'text-base' : 'text-sm'}`}> <p className={`text-[var(--text-secondary)] ${isMobile ? 'text-base' : 'text-sm'}`}>
@@ -176,91 +321,47 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
</div> </div>
) : ( ) : (
<div className="divide-y divide-[var(--border-primary)]"> <div className="divide-y divide-[var(--border-primary)]">
{notifications.map((notification) => { {useEnrichedAlerts ? (
const SeverityIcon = getSeverityIcon(notification.severity); // Render enriched alerts
enrichedAlerts
return ( .sort((a, b) => b.priority_score - a.priority_score)
<div .map((alert) => (
key={notification.id} <EnrichedAlertItem
className={clsx( key={alert.id}
"transition-colors hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)]", alert={alert}
isMobile ? 'px-4 py-4 mx-2 my-1 rounded-lg' : 'p-3', isMobile={isMobile}
!notification.read && "bg-[var(--color-info)]/5" onMarkAsRead={onMarkAsRead}
)} onRemove={onRemoveNotification}
> actionHandler={actionHandler}
<div className={`flex gap-${isMobile ? '4' : '3'}`}> />
{/* Icon */} ))
<div ) : (
className={`flex-shrink-0 rounded-full mt-0.5 ${isMobile ? 'p-2' : 'p-1'}`} // Render notifications as enriched alerts (NotificationData now has enriched fields)
style={{ backgroundColor: getSeverityColor(notification.severity) + '15' }} notifications
> .sort((a, b) => b.priority_score - a.priority_score)
<SeverityIcon .map((notification) => (
className={`${isMobile ? 'w-5 h-5' : 'w-3 h-3'}`} <EnrichedAlertItem
style={{ color: getSeverityColor(notification.severity) }} key={notification.id}
/> alert={{
</div> ...notification,
tenant_id: user?.tenant_id || '',
{/* Content */} status: notification.read ? 'acknowledged' : 'active',
<div className="flex-1 min-w-0"> created_at: notification.timestamp,
{/* Header */} enriched_at: notification.timestamp,
<div className={`flex items-start justify-between gap-2 ${isMobile ? 'mb-2' : 'mb-1'}`}> alert_metadata: notification.metadata || {},
<div className={`flex items-center gap-2 ${isMobile ? 'flex-wrap' : ''}`}> service: 'notification-service',
<Badge variant={getSeverityBadge(notification.severity)} size={isMobile ? "md" : "sm"}> alert_type: notification.item_type,
{notification.severity.toUpperCase()} actions: notification.actions || [],
</Badge> is_group_summary: false,
<Badge variant="secondary" size={isMobile ? "md" : "sm"}> placement: notification.placement || ['notification_panel']
{notification.item_type === 'alert' ? 'Alerta' : 'Recomendación'} } as EnrichedAlert}
</Badge> isMobile={isMobile}
</div> onMarkAsRead={onMarkAsRead}
<span className={`font-medium text-[var(--text-secondary)] ${isMobile ? 'text-sm' : 'text-xs'}`}> onRemove={onRemoveNotification}
{formatTimestamp(notification.timestamp)} actionHandler={actionHandler}
</span> />
</div> ))
)}
{/* Title */}
<p className={`font-medium leading-tight text-[var(--text-primary)] ${
isMobile ? 'text-base mb-2' : 'text-sm mb-1'
}`}>
{notification.title}
</p>
{/* Message */}
<p className={`leading-relaxed text-[var(--text-secondary)] ${
isMobile ? 'text-sm mb-4' : 'text-xs mb-2'
}`}>
{notification.message}
</p>
{/* Actions */}
<div className={`flex items-center gap-2 ${isMobile ? 'flex-col sm:flex-row' : ''}`}>
{!notification.read && (
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onMarkAsRead(notification.id)}
className={`${isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'}`}
>
<Check className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Marcar como leído
</Button>
)}
<Button
variant="ghost"
size={isMobile ? "md" : "sm"}
onClick={() => onRemoveNotification(notification.id)}
className={`text-[var(--color-error)] hover:text-[var(--color-error-dark)] ${
isMobile ? 'w-full sm:w-auto px-4 py-2 text-sm' : 'h-6 px-2 text-xs'
}`}
>
<Trash2 className={`${isMobile ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'}`} />
Eliminar
</Button>
</div>
</div>
</div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -17,7 +17,7 @@ interface SSEContextType {
addEventListener: (eventType: string, callback: (data: any) => void) => () => void; addEventListener: (eventType: string, callback: (data: any) => void) => () => void;
} }
const SSEContext = createContext<SSEContextType | undefined>(undefined); export const SSEContext = createContext<SSEContextType | undefined>(undefined);
export const useSSE = () => { export const useSSE = () => {
const context = useContext(SSEContext); const context = useContext(SSEContext);
@@ -105,56 +105,6 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
} }
}; };
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle different SSE message types from notification service
if (data.status === 'keepalive') {
console.log('SSE keepalive received');
return;
}
const sseEvent: SSEEvent = {
type: data.item_type || 'message',
data: data,
timestamp: data.timestamp || new Date().toISOString(),
};
setLastEvent(sseEvent);
// Show notification if it's an alert or recommendation
if (data.item_type && ['alert', 'recommendation'].includes(data.item_type)) {
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
if (data.item_type === 'alert') {
if (data.severity === 'urgent') toastType = 'error';
else if (data.severity === 'high') toastType = 'error';
else if (data.severity === 'medium') toastType = 'warning';
else toastType = 'info';
} else if (data.item_type === 'recommendation') {
toastType = 'info';
}
showToast[toastType](data.message, { title: data.title || 'Notificación', duration: data.severity === 'urgent' ? 0 : 5000 });
}
// Trigger registered listeners
const listeners = eventListenersRef.current.get(sseEvent.type);
if (listeners) {
listeners.forEach(callback => callback(data));
}
// Also trigger 'message' listeners for backward compatibility
const messageListeners = eventListenersRef.current.get('message');
if (messageListeners) {
messageListeners.forEach(callback => callback(data));
}
} catch (error) {
console.error('Error parsing SSE message:', error);
}
};
// Handle connection confirmation from gateway // Handle connection confirmation from gateway
eventSource.addEventListener('connection', (event) => { eventSource.addEventListener('connection', (event) => {
try { try {
@@ -175,7 +125,7 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
} }
}); });
// Handle alert events // Handle alert events (enriched alerts from alert-processor)
eventSource.addEventListener('alert', (event) => { eventSource.addEventListener('alert', (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
@@ -187,114 +137,45 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
setLastEvent(sseEvent); setLastEvent(sseEvent);
// Show alert toast // Determine toast type based on enriched priority_level and type_class
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info'; let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
if (data.severity === 'urgent') toastType = 'error';
else if (data.severity === 'high') toastType = 'error';
else if (data.severity === 'medium') toastType = 'warning';
else toastType = 'info';
showToast[toastType](data.message, { title: data.title || 'Alerta', duration: data.severity === 'urgent' ? 0 : 5000 }); // Use success toast for prevented_issue type (AI already handled)
if (data.type_class === 'prevented_issue') {
toastType = 'success';
} else if (data.priority_level === 'critical') {
toastType = 'error';
} else if (data.priority_level === 'important') {
toastType = 'warning';
} else if (data.priority_level === 'standard') {
toastType = 'info';
}
// Trigger listeners // Show toast with enriched data
const title = data.title || 'Alerta';
const duration = data.priority_level === 'critical' ? 0 : 5000;
// Add financial impact to message if available
let message = data.message;
if (data.business_impact?.financial_impact_eur) {
message = `${data.message} • €${data.business_impact.financial_impact_eur} en riesgo`;
}
showToast[toastType](message, { title, duration });
// Trigger listeners with enriched alert data
// Wrap in queueMicrotask to prevent setState during render warnings
const listeners = eventListenersRef.current.get('alert'); const listeners = eventListenersRef.current.get('alert');
if (listeners) { if (listeners) {
listeners.forEach(callback => callback(data)); listeners.forEach(callback => {
queueMicrotask(() => callback(data));
});
} }
} catch (error) { } catch (error) {
console.error('Error parsing alert event:', error); console.error('Error parsing alert event:', error);
} }
}); });
// Handle recommendation events
eventSource.addEventListener('recommendation', (event) => {
try {
const data = JSON.parse(event.data);
const sseEvent: SSEEvent = {
type: 'recommendation',
data,
timestamp: data.timestamp || new Date().toISOString(),
};
setLastEvent(sseEvent);
// Show recommendation toast
showToast.info(data.message, { title: data.title || 'Recomendación', duration: 5000 });
// Trigger listeners
const listeners = eventListenersRef.current.get('recommendation');
if (listeners) {
listeners.forEach(callback => callback(data));
}
} catch (error) {
console.error('Error parsing recommendation event:', error);
}
});
// Handle inventory_alert events (high/urgent severity alerts from gateway)
eventSource.addEventListener('inventory_alert', (event) => {
try {
const data = JSON.parse(event.data);
const sseEvent: SSEEvent = {
type: 'alert',
data,
timestamp: data.timestamp || new Date().toISOString(),
};
setLastEvent(sseEvent);
// Show urgent alert toast
const toastType = data.severity === 'urgent' ? 'error' : 'error';
showToast[toastType](data.message, { title: data.title || 'Alerta de Inventario', duration: data.severity === 'urgent' ? 0 : 5000 });
// Trigger alert listeners
const listeners = eventListenersRef.current.get('alert');
if (listeners) {
listeners.forEach(callback => callback(data));
}
} catch (error) {
console.error('Error parsing inventory_alert event:', error);
}
});
// Handle generic notification events from gateway
eventSource.addEventListener('notification', (event) => {
try {
const data = JSON.parse(event.data);
const sseEvent: SSEEvent = {
type: data.item_type || 'notification',
data,
timestamp: data.timestamp || new Date().toISOString(),
};
setLastEvent(sseEvent);
// Show notification toast
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
if (data.severity === 'urgent') toastType = 'error';
else if (data.severity === 'high') toastType = 'warning';
else if (data.severity === 'medium') toastType = 'info';
showToast[toastType](data.message, { title: data.title || 'Notificación', duration: data.severity === 'urgent' ? 0 : 5000 });
// Trigger listeners for both notification and specific type
const notificationListeners = eventListenersRef.current.get('notification');
if (notificationListeners) {
notificationListeners.forEach(callback => callback(data));
}
if (data.item_type) {
const typeListeners = eventListenersRef.current.get(data.item_type);
if (typeListeners) {
typeListeners.forEach(callback => callback(data));
}
}
} catch (error) {
console.error('Error parsing notification event:', error);
}
});
eventSource.onerror = (error) => { eventSource.onerror = (error) => {
console.error('SSE connection error:', error); console.error('SSE connection error:', error);
setIsConnected(false); setIsConnected(false);

View File

@@ -57,21 +57,30 @@ export const shouldStartTour = (): boolean => {
const tourState = getTourState(); const tourState = getTourState();
const shouldStart = sessionStorage.getItem('demo_tour_should_start') === 'true'; const shouldStart = sessionStorage.getItem('demo_tour_should_start') === 'true';
console.log('[shouldStartTour] tourState:', tourState); // Only log in development to prevent excessive console output that might affect performance
console.log('[shouldStartTour] shouldStart flag:', shouldStart); if (process.env.NODE_ENV === 'development') {
console.log('[shouldStartTour] tourState:', tourState);
console.log('[shouldStartTour] shouldStart flag:', shouldStart);
}
// If explicitly marked to start, always start (unless already completed) // If explicitly marked to start, always start (unless already completed)
if (shouldStart) { if (shouldStart) {
if (tourState && tourState.completed) { if (tourState && tourState.completed) {
console.log('[shouldStartTour] Tour already completed, not starting'); if (process.env.NODE_ENV === 'development') {
console.log('[shouldStartTour] Tour already completed, not starting');
}
return false; return false;
} }
console.log('[shouldStartTour] Should start flag is true, starting tour'); if (process.env.NODE_ENV === 'development') {
console.log('[shouldStartTour] Should start flag is true, starting tour');
}
return true; return true;
} }
// No explicit start flag, don't auto-start // No explicit start flag, don't auto-start
console.log('[shouldStartTour] No start flag, not starting'); if (process.env.NODE_ENV === 'development') {
console.log('[shouldStartTour] No start flag, not starting');
}
return false; return false;
}; };

View File

@@ -1,5 +1,25 @@
// Export commonly used hooks // Export commonly used hooks
export { default as useLocalStorage } from './useLocalStorage';
export { default as useDebounce } from './useDebounce';
export { default as useSubscription } from '../api/hooks/subscription';
export { useTenantId, useTenantInfo, useRequiredTenant } from './useTenantId'; export { useTenantId, useTenantInfo, useRequiredTenant } from './useTenantId';
// Export new event system hooks
export { useSSEEvents, useSSEEventsWithDedupe } from './useSSE';
export {
useEventNotifications,
useNotificationsByDomain,
useProductionNotifications,
useInventoryNotifications,
useSupplyChainNotifications,
useOperationsNotifications,
useBatchNotifications,
useDeliveryNotifications,
useOrchestrationNotifications,
} from './useEventNotifications';
export {
useRecommendations,
useRecommendationsByDomain,
useRecommendationsByType,
useDemandRecommendations,
useInventoryOptimizationRecommendations,
useCostReductionRecommendations,
useHighConfidenceRecommendations,
} from './useRecommendations';

View File

@@ -1,71 +0,0 @@
/**
* useAlertActions Hook
* Provides contextual actions for alerts
*/
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { NotificationData } from './useNotifications';
import { getContextualActions, type ContextualAction } from '../utils/alertHelpers';
export interface UseAlertActionsReturn {
getActions: (alert: NotificationData) => ContextualAction[];
executeAction: (alert: NotificationData, action: ContextualAction) => void;
}
/**
* Hook to manage alert actions
*/
export function useAlertActions(): UseAlertActionsReturn {
const navigate = useNavigate();
const getActions = useCallback((alert: NotificationData): ContextualAction[] => {
return getContextualActions(alert);
}, []);
const executeAction = useCallback((alert: NotificationData, action: ContextualAction) => {
switch (action.action) {
case 'order_stock':
if (action.route) {
navigate(action.route);
}
break;
case 'plan_usage':
if (action.route) {
navigate(action.route);
}
break;
case 'schedule_maintenance':
if (action.route) {
navigate(action.route);
}
break;
case 'contact_customer':
// In a real app, this would open a communication modal
console.log('Contact customer for alert:', alert.id);
break;
case 'view_production':
if (action.route) {
navigate(action.route);
}
break;
case 'view_details':
// Default: expand the alert or navigate to details
console.log('View details for alert:', alert.id);
break;
default:
console.log('Unknown action:', action.action);
}
}, [navigate]);
return {
getActions,
executeAction,
};
}

View File

@@ -1,181 +0,0 @@
/**
* useAlertAnalytics Hook
* Fetches analytics data from backend API
*/
import { useState, useEffect, useCallback } from 'react';
import { NotificationData } from './useNotifications';
import type { AlertCategory } from '../utils/alertHelpers';
import { useCurrentTenant } from '../stores/tenant.store';
import { useAuthUser } from '../stores/auth.store';
export interface AlertTrendData {
date: string;
count: number;
urgentCount: number;
highCount: number;
mediumCount: number;
lowCount: number;
}
export interface AlertAnalytics {
trends: AlertTrendData[];
averageResponseTime: number;
topCategories: Array<{ category: AlertCategory | string; count: number; percentage: number }>;
totalAlerts: number;
resolvedAlerts: number;
activeAlerts: number;
resolutionRate: number;
predictedDailyAverage: number;
busiestDay: string;
}
/**
* Hook to fetch and display alert analytics from backend
*/
export function useAlertAnalytics(alerts: NotificationData[], days: number = 7): AlertAnalytics {
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id;
const [analytics, setAnalytics] = useState<AlertAnalytics>({
trends: [],
averageResponseTime: 0,
topCategories: [],
totalAlerts: 0,
resolvedAlerts: 0,
activeAlerts: 0,
resolutionRate: 0,
predictedDailyAverage: 0,
busiestDay: 'N/A',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch analytics from backend
const fetchAnalytics = useCallback(async () => {
if (!tenantId) {
console.warn('[useAlertAnalytics] No tenant ID found, skipping analytics fetch');
return;
}
console.log('[useAlertAnalytics] Fetching analytics for tenant:', tenantId, 'days:', days);
setLoading(true);
setError(null);
try {
const { getAlertAnalytics } = await import('../api/services/alert_analytics');
const data = await getAlertAnalytics(tenantId, days);
console.log('[useAlertAnalytics] Received data from API:', data);
setAnalytics(data);
console.log('[useAlertAnalytics] Analytics state updated');
} catch (err) {
console.error('[useAlertAnalytics] Failed to fetch alert analytics:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch analytics');
// Fallback to empty state on error
setAnalytics({
trends: [],
averageResponseTime: 0,
topCategories: [],
totalAlerts: alerts.length,
resolvedAlerts: 0,
activeAlerts: alerts.length,
resolutionRate: 0,
predictedDailyAverage: 0,
busiestDay: 'N/A',
});
} finally {
setLoading(false);
}
}, [tenantId, days, alerts.length]);
// Fetch on mount and when days changes
useEffect(() => {
fetchAnalytics();
}, [fetchAnalytics]);
// Refetch when new alerts arrive (debounced)
useEffect(() => {
const timer = setTimeout(() => {
fetchAnalytics();
}, 2000);
return () => clearTimeout(timer);
}, [alerts.length, fetchAnalytics]);
return analytics;
}
/**
* Hook to export analytics tracking methods
* Uses backend API for persistent tracking across devices
*/
export function useAlertAnalyticsTracking() {
const { trackAlertInteraction } = useAlertInteractions();
const trackAcknowledgment = useCallback(async (alertId: string) => {
try {
await trackAlertInteraction(alertId, 'acknowledged');
} catch (error) {
console.error('Failed to track acknowledgment:', error);
}
}, [trackAlertInteraction]);
const trackResolution = useCallback(async (alertId: string) => {
try {
await trackAlertInteraction(alertId, 'resolved');
} catch (error) {
console.error('Failed to track resolution:', error);
}
}, [trackAlertInteraction]);
const trackSnooze = useCallback(async (alertId: string, duration: string) => {
try {
await trackAlertInteraction(alertId, 'snoozed', { duration });
} catch (error) {
console.error('Failed to track snooze:', error);
}
}, [trackAlertInteraction]);
const trackDismiss = useCallback(async (alertId: string, reason?: string) => {
try {
await trackAlertInteraction(alertId, 'dismissed', { reason });
} catch (error) {
console.error('Failed to track dismiss:', error);
}
}, [trackAlertInteraction]);
return {
trackAcknowledgment,
trackResolution,
trackSnooze,
trackDismiss,
};
}
/**
* Hook for tracking alert interactions
*/
function useAlertInteractions() {
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id;
const trackAlertInteraction = useCallback(async (
alertId: string,
interactionType: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed',
metadata?: Record<string, any>
) => {
if (!tenantId) {
console.warn('No tenant ID found, skipping interaction tracking');
return;
}
const { trackAlertInteraction: apiTrack } = await import('../api/services/alert_analytics');
await apiTrack(tenantId, alertId, interactionType, metadata);
}, [tenantId]);
return { trackAlertInteraction };
}

View File

@@ -1,112 +0,0 @@
/**
* useAlertFilters Hook
* Manages alert filtering state and logic
*/
import { useState, useCallback, useMemo } from 'react';
import type { AlertFilters, AlertSeverity, AlertCategory, TimeGroup } from '../utils/alertHelpers';
export interface UseAlertFiltersReturn {
filters: AlertFilters;
setFilters: React.Dispatch<React.SetStateAction<AlertFilters>>;
toggleSeverity: (severity: AlertSeverity) => void;
toggleCategory: (category: AlertCategory) => void;
setTimeRange: (range: TimeGroup | 'all') => void;
setSearch: (search: string) => void;
toggleShowSnoozed: () => void;
clearFilters: () => void;
hasActiveFilters: boolean;
activeFilterCount: number;
}
const DEFAULT_FILTERS: AlertFilters = {
severities: [],
categories: [],
timeRange: 'all',
search: '',
showSnoozed: false,
};
/**
* Hook to manage alert filters
*/
export function useAlertFilters(initialFilters: Partial<AlertFilters> = {}): UseAlertFiltersReturn {
const [filters, setFilters] = useState<AlertFilters>({
...DEFAULT_FILTERS,
...initialFilters,
});
const toggleSeverity = useCallback((severity: AlertSeverity) => {
setFilters(prev => ({
...prev,
severities: prev.severities.includes(severity)
? prev.severities.filter(s => s !== severity)
: [...prev.severities, severity],
}));
}, []);
const toggleCategory = useCallback((category: AlertCategory) => {
setFilters(prev => ({
...prev,
categories: prev.categories.includes(category)
? prev.categories.filter(c => c !== category)
: [...prev.categories, category],
}));
}, []);
const setTimeRange = useCallback((range: TimeGroup | 'all') => {
setFilters(prev => ({
...prev,
timeRange: range,
}));
}, []);
const setSearch = useCallback((search: string) => {
setFilters(prev => ({
...prev,
search,
}));
}, []);
const toggleShowSnoozed = useCallback(() => {
setFilters(prev => ({
...prev,
showSnoozed: !prev.showSnoozed,
}));
}, []);
const clearFilters = useCallback(() => {
setFilters(DEFAULT_FILTERS);
}, []);
const hasActiveFilters = useMemo(() => {
return (
filters.severities.length > 0 ||
filters.categories.length > 0 ||
filters.timeRange !== 'all' ||
filters.search.trim() !== ''
);
}, [filters]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.severities.length > 0) count += filters.severities.length;
if (filters.categories.length > 0) count += filters.categories.length;
if (filters.timeRange !== 'all') count += 1;
if (filters.search.trim() !== '') count += 1;
return count;
}, [filters]);
return {
filters,
setFilters,
toggleSeverity,
toggleCategory,
setTimeRange,
setSearch,
toggleShowSnoozed,
clearFilters,
hasActiveFilters,
activeFilterCount,
};
}

View File

@@ -1,102 +0,0 @@
/**
* useAlertGrouping Hook
* Manages alert grouping logic and state
*/
import { useMemo, useState, useCallback } from 'react';
import { NotificationData } from './useNotifications';
import {
groupAlertsByTime,
groupAlertsByCategory,
groupSimilarAlerts,
sortAlerts,
type AlertGroup,
} from '../utils/alertHelpers';
export type GroupingMode = 'none' | 'time' | 'category' | 'similarity';
export interface UseAlertGroupingReturn {
groupedAlerts: AlertGroup[];
groupingMode: GroupingMode;
setGroupingMode: (mode: GroupingMode) => void;
collapsedGroups: Set<string>;
toggleGroupCollapse: (groupId: string) => void;
collapseAll: () => void;
expandAll: () => void;
isGroupCollapsed: (groupId: string) => boolean;
}
/**
* Hook to manage alert grouping
*/
export function useAlertGrouping(
alerts: NotificationData[],
initialMode: GroupingMode = 'time'
): UseAlertGroupingReturn {
const [groupingMode, setGroupingMode] = useState<GroupingMode>(initialMode);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const groupedAlerts = useMemo(() => {
// Sort alerts first
const sortedAlerts = sortAlerts(alerts);
switch (groupingMode) {
case 'time':
return groupAlertsByTime(sortedAlerts);
case 'category':
return groupAlertsByCategory(sortedAlerts);
case 'similarity':
return groupSimilarAlerts(sortedAlerts);
case 'none':
default:
// Return each alert as its own group
return sortedAlerts.map((alert, index) => ({
id: `single-${alert.id}`,
type: 'time' as const,
key: alert.id,
title: alert.title,
count: 1,
severity: alert.severity as any,
alerts: [alert],
}));
}
}, [alerts, groupingMode]);
const toggleGroupCollapse = useCallback((groupId: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
}, []);
const collapseAll = useCallback(() => {
setCollapsedGroups(new Set(groupedAlerts.map(g => g.id)));
}, [groupedAlerts]);
const expandAll = useCallback(() => {
setCollapsedGroups(new Set());
}, []);
const isGroupCollapsed = useCallback((groupId: string) => {
return collapsedGroups.has(groupId);
}, [collapsedGroups]);
return {
groupedAlerts,
groupingMode,
setGroupingMode,
collapsedGroups,
toggleGroupCollapse,
collapseAll,
expandAll,
isGroupCollapsed,
};
}

View File

@@ -0,0 +1,318 @@
/**
* useEventNotifications Hook
*
* Subscribe to lightweight informational notifications from the new event architecture.
* Subscribes to domain-specific notification channels via SSE.
*
* Examples:
* const { notifications } = useEventNotifications(); // All notifications
* const { notifications } = useEventNotifications({ domains: ['production'], maxAge: 3600 });
* const { notifications } = useEventNotifications({ eventTypes: ['batch_completed', 'batch_started'] });
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useSSEEvents } from './useSSE';
import type {
Notification,
EventDomain,
UseNotificationsConfig,
} from '../types/events';
import { isNotification } from '../types/events';
interface UseEventNotificationsReturn {
notifications: Notification[];
recentNotifications: Notification[];
isLoading: boolean;
clearNotifications: () => void;
}
const DEFAULT_MAX_AGE = 3600; // 1 hour
export function useEventNotifications(config: UseNotificationsConfig = {}): UseEventNotificationsReturn {
const {
domains,
eventTypes,
maxAge = DEFAULT_MAX_AGE,
} = config;
// Determine which channels to subscribe to
const channels = useMemo(() => {
if (!domains || domains.length === 0) {
// Subscribe to all notification channels
return ['*.notifications'];
}
// Subscribe to specific domain notification channels
return domains.map(domain => `${domain}.notifications`);
}, [domains]);
// Subscribe to SSE with channel filter
const { events, isConnected } = useSSEEvents({ channels });
// Filter events to notifications only
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Use refs to track previous values and prevent unnecessary updates
const prevEventIdsRef = useRef<string>('');
const prevEventTypesRef = useRef<string[]>([]);
const prevMaxAgeRef = useRef<number>(maxAge);
const prevDomainsRef = useRef<string[]>(domains || []);
useEffect(() => {
// Check if the configuration has actually changed
const currentEventTypes = eventTypes || [];
const currentDomains = domains || [];
const configChanged =
JSON.stringify(currentEventTypes) !== JSON.stringify(prevEventTypesRef.current) ||
prevMaxAgeRef.current !== maxAge ||
JSON.stringify(currentDomains) !== JSON.stringify(prevDomainsRef.current);
// Update refs with current values
prevEventTypesRef.current = currentEventTypes;
prevMaxAgeRef.current = maxAge;
prevDomainsRef.current = currentDomains;
// 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
}
// Update the previous event IDs
prevEventIdsRef.current = currentEventIds;
console.log('🔵 [useEventNotifications] Effect triggered', {
eventsCount: events.length,
eventTypes,
maxAge,
domains,
channels,
});
// Filter and process incoming events
const notificationEvents = events.filter(isNotification);
console.log('🔵 [useEventNotifications] After isNotification filter:', {
notificationEventsCount: notificationEvents.length,
eventIds: notificationEvents.map(e => e.id).join(','),
});
// Apply filters
let filtered = notificationEvents;
// Filter by event types
if (eventTypes && eventTypes.length > 0) {
filtered = filtered.filter(notification =>
eventTypes.includes(notification.event_type)
);
console.log('🔵 [useEventNotifications] After event type filter:', {
filteredCount: filtered.length,
requestedTypes: eventTypes,
});
}
// Filter by age (maxAge in seconds)
if (maxAge) {
const maxAgeMs = maxAge * 1000;
const now = Date.now();
filtered = filtered.filter(notification => {
const notificationTime = new Date(notification.created_at).getTime();
return now - notificationTime <= maxAgeMs;
});
console.log('🔵 [useEventNotifications] After age filter:', {
filteredCount: filtered.length,
maxAge,
});
}
// Filter expired notifications (TTL)
filtered = filtered.filter(notification => {
if (notification.expires_at) {
const expiryTime = new Date(notification.expires_at).getTime();
return Date.now() < expiryTime;
}
return true;
});
console.log('🔵 [useEventNotifications] After expiry filter:', {
filteredCount: filtered.length,
});
// Sort by timestamp (newest first)
filtered.sort((a, b) => {
const timeA = new Date(a.created_at).getTime();
const timeB = new Date(b.created_at).getTime();
return timeB - timeA;
});
console.log('🔵 [useEventNotifications] Setting notifications state', {
filteredCount: filtered.length,
filteredIds: filtered.map(n => n.id).join(','),
firstId: filtered[0]?.id,
});
// Only update state if the IDs have actually changed to prevent infinite loops
setNotifications(prev => {
const prevIds = prev.map(n => n.id).join(',');
const newIds = filtered.map(n => n.id).join(',');
console.log('🔵 [useEventNotifications] Comparing IDs:', {
prevIds,
newIds,
hasChanged: prevIds !== newIds,
});
if (prevIds === newIds) {
console.log('🔵 [useEventNotifications] IDs unchanged, keeping previous array reference');
return prev; // Keep same reference if IDs haven't changed
}
console.log('🔵 [useEventNotifications] IDs changed, updating state');
return filtered;
});
setIsLoading(false);
}, [events, eventTypes, maxAge, domains, channels]); // Include all dependencies
// Computed values
const recentNotifications = useMemo(() => {
// Last 10 notifications
return notifications.slice(0, 10);
}, [notifications]);
const clearNotifications = useCallback(() => {
setNotifications([]);
}, []);
return {
notifications,
recentNotifications,
isLoading: isLoading || !isConnected,
clearNotifications,
};
}
/**
* useNotificationsByDomain Hook
*
* Get notifications grouped by domain.
*/
export function useNotificationsByDomain(config: UseNotificationsConfig = {}) {
const { notifications, ...rest } = useEventNotifications(config);
const notificationsByDomain = useMemo(() => {
const grouped: Partial<Record<EventDomain, Notification[]>> = {};
notifications.forEach(notification => {
if (!grouped[notification.event_domain]) {
grouped[notification.event_domain] = [];
}
grouped[notification.event_domain]!.push(notification);
});
return grouped;
}, [notifications]);
return {
notifications,
notificationsByDomain,
...rest,
};
}
/**
* useProductionNotifications Hook
*
* Convenience hook for production domain notifications.
*/
export function useProductionNotifications(eventTypes?: string[]) {
return useEventNotifications({
domains: ['production'],
eventTypes,
});
}
/**
* useInventoryNotifications Hook
*
* Convenience hook for inventory domain notifications.
*/
export function useInventoryNotifications(eventTypes?: string[]) {
return useEventNotifications({
domains: ['inventory'],
eventTypes,
});
}
/**
* useSupplyChainNotifications Hook
*
* Convenience hook for supply chain domain notifications.
*/
export function useSupplyChainNotifications(eventTypes?: string[]) {
return useEventNotifications({
domains: ['supply_chain'],
eventTypes,
});
}
/**
* useOperationsNotifications Hook
*
* Convenience hook for operations domain notifications (orchestration).
*/
export function useOperationsNotifications(eventTypes?: string[]) {
return useEventNotifications({
domains: ['operations'],
eventTypes,
});
}
/**
* useBatchNotifications Hook
*
* Convenience hook for production batch-related notifications.
*/
export function useBatchNotifications() {
return useEventNotifications({
domains: ['production'],
eventTypes: [
'batch_state_changed',
'batch_completed',
'batch_started',
],
});
}
/**
* useDeliveryNotifications Hook
*
* Convenience hook for delivery-related notifications.
*/
export function useDeliveryNotifications() {
return useEventNotifications({
domains: ['supply_chain'],
eventTypes: [
'delivery_scheduled',
'delivery_arriving_soon',
'delivery_received',
],
});
}
/**
* useOrchestrationNotifications Hook
*
* Convenience hook for orchestration run notifications.
*/
export function useOrchestrationNotifications() {
return useEventNotifications({
domains: ['operations'],
eventTypes: [
'orchestration_run_started',
'orchestration_run_completed',
'action_created',
'action_completed',
],
});
}

View File

@@ -1,16 +1,58 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useSSE } from '../contexts/SSEContext'; import { useSSE } from '../contexts/SSEContext';
import { calculateSnoozeUntil, type SnoozedAlert } from '../utils/alertHelpers'; import { calculateSnoozeUntil, type SnoozedAlert } from '../utils/alertHelpers';
export interface NotificationData { export interface NotificationData {
id: string; id: string;
item_type: 'alert' | 'recommendation'; item_type: 'alert' | 'recommendation';
severity: 'urgent' | 'high' | 'medium' | 'low';
title: string; title: string;
message: string; message: string;
timestamp: string; timestamp: string;
read: boolean; read: boolean;
metadata?: Record<string, any>; 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 STORAGE_KEY = 'bakery-notifications';
@@ -165,12 +207,25 @@ export const useNotifications = () => {
const initialNotifications: NotificationData[] = data.map(item => ({ const initialNotifications: NotificationData[] = data.map(item => ({
id: item.id, id: item.id,
item_type: item.item_type, item_type: item.item_type,
severity: item.severity,
title: item.title, title: item.title,
message: item.message, message: item.message,
timestamp: item.timestamp || new Date().toISOString(), timestamp: item.timestamp || new Date().toISOString(),
read: false, read: false,
metadata: item.metadata, 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 => { setNotifications(prev => {
@@ -188,17 +243,30 @@ export const useNotifications = () => {
} }
}); });
// Listen for alert events // Listen for alert events (enriched alerts from alert-processor)
const removeAlertListener = addEventListener('alert', (data: any) => { const removeAlertListener = addEventListener('alert', (data: any) => {
const notification: NotificationData = { const notification: NotificationData = {
id: data.id, id: data.id,
item_type: 'alert', item_type: 'alert',
severity: data.severity,
title: data.title, title: data.title,
message: data.message, message: data.message,
timestamp: data.timestamp || new Date().toISOString(), timestamp: data.timestamp || new Date().toISOString(),
read: false, read: false,
metadata: data.metadata, 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 => { setNotifications(prev => {
@@ -206,7 +274,9 @@ export const useNotifications = () => {
const exists = prev.some(n => n.id === notification.id); const exists = prev.some(n => n.id === notification.id);
if (exists) return prev; if (exists) return prev;
return [notification, ...prev].slice(0, 50); // Keep last 50 notifications 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); setUnreadCount(prev => prev + 1);
}); });
@@ -216,12 +286,25 @@ export const useNotifications = () => {
const notification: NotificationData = { const notification: NotificationData = {
id: data.id, id: data.id,
item_type: 'recommendation', item_type: 'recommendation',
severity: data.severity,
title: data.title, title: data.title,
message: data.message, message: data.message,
timestamp: data.timestamp || new Date().toISOString(), timestamp: data.timestamp || new Date().toISOString(),
read: false, read: false,
metadata: data.metadata, 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 => { setNotifications(prev => {
@@ -229,7 +312,9 @@ export const useNotifications = () => {
const exists = prev.some(n => n.id === notification.id); const exists = prev.some(n => n.id === notification.id);
if (exists) return prev; if (exists) return prev;
return [notification, ...prev].slice(0, 50); // Keep last 50 notifications 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); setUnreadCount(prev => prev + 1);
}); });
@@ -260,12 +345,17 @@ export const useNotifications = () => {
}, []); }, []);
const removeNotification = useCallback((notificationId: string) => { const removeNotification = useCallback((notificationId: string) => {
const notification = notifications.find(n => n.id === notificationId); // Use functional state update to avoid dependency on notifications array
setNotifications(prev => prev.filter(n => n.id !== notificationId)); setNotifications(prev => {
const notification = prev.find(n => n.id === notificationId);
if (notification && !notification.read) { // Update unread count if necessary
setUnreadCount(prev => Math.max(0, prev - 1)); if (notification && !notification.read) {
} setUnreadCount(curr => Math.max(0, curr - 1));
}
return prev.filter(n => n.id !== notificationId);
});
// Also remove from snoozed if present // Also remove from snoozed if present
setSnoozedAlerts(prev => { setSnoozedAlerts(prev => {
@@ -273,7 +363,7 @@ export const useNotifications = () => {
updated.delete(notificationId); updated.delete(notificationId);
return updated; return updated;
}); });
}, [notifications]); }, []); // Fixed: No dependencies needed with functional updates
const clearAllNotifications = useCallback(() => { const clearAllNotifications = useCallback(() => {
setNotifications([]); setNotifications([]);
@@ -301,8 +391,17 @@ export const useNotifications = () => {
}, []); }, []);
// Check if alert is snoozed // 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 isAlertSnoozed = useCallback((alertId: string): boolean => {
const snoozed = snoozedAlerts.get(alertId); const snoozed = snoozedAlertsRef.current.get(alertId);
if (!snoozed) { if (!snoozed) {
return false; return false;
} }
@@ -318,7 +417,7 @@ export const useNotifications = () => {
} }
return true; return true;
}, [snoozedAlerts]); }, []); // Fixed: No dependencies, uses ref for latest state
// Get snoozed alerts that are active // Get snoozed alerts that are active
const activeSnoozedAlerts = useMemo(() => { const activeSnoozedAlerts = useMemo(() => {
@@ -335,25 +434,41 @@ export const useNotifications = () => {
const markMultipleAsRead = useCallback((notificationIds: string[]) => { const markMultipleAsRead = useCallback((notificationIds: string[]) => {
const idsSet = new Set(notificationIds); const idsSet = new Set(notificationIds);
setNotifications(prev => // Use functional state update to avoid dependency on notifications array
prev.map(notification => 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) idsSet.has(notification.id)
? { ...notification, read: true } ? { ...notification, read: true }
: notification : notification
) );
); });
}, []); // Fixed: No dependencies needed with functional updates
const unreadToMark = notifications.filter(n => idsSet.has(n.id) && !n.read).length;
setUnreadCount(prev => Math.max(0, prev - unreadToMark));
}, [notifications]);
const removeMultiple = useCallback((notificationIds: string[]) => { const removeMultiple = useCallback((notificationIds: string[]) => {
const idsSet = new Set(notificationIds); const idsSet = new Set(notificationIds);
const unreadToRemove = notifications.filter(n => idsSet.has(n.id) && !n.read).length; // 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;
setNotifications(prev => prev.filter(n => !idsSet.has(n.id))); // Update unread count
setUnreadCount(prev => Math.max(0, prev - unreadToRemove)); 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 // Also remove from snoozed
setSnoozedAlerts(prev => { setSnoozedAlerts(prev => {
@@ -361,7 +476,7 @@ export const useNotifications = () => {
notificationIds.forEach(id => updated.delete(id)); notificationIds.forEach(id => updated.delete(id));
return updated; return updated;
}); });
}, [notifications]); }, []); // Fixed: No dependencies needed with functional updates
const snoozeMultiple = useCallback((alertIds: string[], duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => { const snoozeMultiple = useCallback((alertIds: string[], duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
const until = calculateSnoozeUntil(duration); const until = calculateSnoozeUntil(duration);

View File

@@ -0,0 +1,230 @@
/**
* useRecommendations Hook
*
* Subscribe to AI-generated recommendations with filtering.
* Subscribes to tenant-wide recommendations channel via SSE.
*
* Examples:
* const { recommendations } = useRecommendations(); // All recommendations
* const { recommendations } = useRecommendations({ domains: ['demand'], minConfidence: 0.8 });
* const { recommendations } = useRecommendations({ includeDismissed: false });
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useSSEEvents } from './useSSE';
import type {
Recommendation,
EventDomain,
UseRecommendationsConfig,
} from '../types/events';
import { isRecommendation } from '../types/events';
interface UseRecommendationsReturn {
recommendations: Recommendation[];
activeRecommendations: Recommendation[];
highConfidenceRecommendations: Recommendation[];
isLoading: boolean;
dismiss: (recommendationId: string) => void;
clearRecommendations: () => void;
}
export function useRecommendations(config: UseRecommendationsConfig = {}): UseRecommendationsReturn {
const {
domains,
includeDismissed = false,
minConfidence = 0.0,
} = config;
// Recommendations are tenant-wide, not domain-specific
// But we can filter by domain after receiving
const channels = useMemo(() => ['recommendations'], []);
// Subscribe to SSE with channel filter
const { events, isConnected } = useSSEEvents({ channels });
// Filter events to recommendations only
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Filter and process incoming events
const recommendationEvents = events.filter(isRecommendation);
// Apply filters
let filtered = recommendationEvents;
// Filter by domain
if (domains && domains.length > 0) {
filtered = filtered.filter(rec => domains.includes(rec.event_domain));
}
// Filter by confidence
if (minConfidence > 0) {
filtered = filtered.filter(rec => {
const confidence = rec.confidence_score ?? 1.0;
return confidence >= minConfidence;
});
}
// Filter dismissed recommendations
if (!includeDismissed) {
filtered = filtered.filter(rec => !rec.dismissed_at);
}
// Sort by confidence (highest first), then by timestamp (newest first)
filtered.sort((a, b) => {
const confidenceA = a.confidence_score ?? 0.5;
const confidenceB = b.confidence_score ?? 0.5;
const confidenceDiff = confidenceB - confidenceA;
if (Math.abs(confidenceDiff) > 0.01) return confidenceDiff;
const timeA = new Date(a.created_at).getTime();
const timeB = new Date(b.created_at).getTime();
return timeB - timeA;
});
setRecommendations(filtered);
setIsLoading(false);
}, [events, domains, minConfidence, includeDismissed]);
// Computed values
const activeRecommendations = useMemo(() => {
return recommendations.filter(rec => !rec.dismissed_at);
}, [recommendations]);
const highConfidenceRecommendations = useMemo(() => {
return recommendations.filter(rec => (rec.confidence_score ?? 0.5) >= 0.8);
}, [recommendations]);
// Actions
const dismiss = useCallback((recommendationId: string) => {
setRecommendations(prev =>
prev.map(rec =>
rec.id === recommendationId
? { ...rec, dismissed_at: new Date().toISOString() }
: rec
)
);
// TODO: Send dismissal to backend
}, []);
const clearRecommendations = useCallback(() => {
setRecommendations([]);
}, []);
return {
recommendations,
activeRecommendations,
highConfidenceRecommendations,
isLoading: isLoading || !isConnected,
dismiss,
clearRecommendations,
};
}
/**
* useRecommendationsByDomain Hook
*
* Get recommendations grouped by domain.
*/
export function useRecommendationsByDomain(config: UseRecommendationsConfig = {}) {
const { recommendations, ...rest } = useRecommendations(config);
const recommendationsByDomain = useMemo(() => {
const grouped: Partial<Record<EventDomain, Recommendation[]>> = {};
recommendations.forEach(rec => {
if (!grouped[rec.event_domain]) {
grouped[rec.event_domain] = [];
}
grouped[rec.event_domain]!.push(rec);
});
return grouped;
}, [recommendations]);
return {
recommendations,
recommendationsByDomain,
...rest,
};
}
/**
* useRecommendationsByType Hook
*
* Get recommendations grouped by recommendation type.
*/
export function useRecommendationsByType(config: UseRecommendationsConfig = {}) {
const { recommendations, ...rest } = useRecommendations(config);
const recommendationsByType = useMemo(() => {
const grouped: Partial<Record<string, Recommendation[]>> = {};
recommendations.forEach(rec => {
if (!grouped[rec.recommendation_type]) {
grouped[rec.recommendation_type] = [];
}
grouped[rec.recommendation_type]!.push(rec);
});
return grouped;
}, [recommendations]);
return {
recommendations,
recommendationsByType,
...rest,
};
}
/**
* useDemandRecommendations Hook
*
* Convenience hook for demand/forecasting recommendations.
*/
export function useDemandRecommendations() {
return useRecommendations({
domains: ['demand'],
includeDismissed: false,
});
}
/**
* useInventoryOptimizationRecommendations Hook
*
* Convenience hook for inventory optimization recommendations.
*/
export function useInventoryOptimizationRecommendations() {
return useRecommendations({
domains: ['inventory'],
includeDismissed: false,
minConfidence: 0.7,
});
}
/**
* useCostReductionRecommendations Hook
*
* Convenience hook for cost reduction recommendations.
*/
export function useCostReductionRecommendations() {
return useRecommendations({
domains: ['supply_chain'],
includeDismissed: false,
minConfidence: 0.75,
});
}
/**
* useHighConfidenceRecommendations Hook
*
* Convenience hook for high-confidence recommendations (>= 80%).
*/
export function useHighConfidenceRecommendations(domains?: EventDomain[]) {
return useRecommendations({
domains,
includeDismissed: false,
minConfidence: 0.8,
});
}

View File

@@ -0,0 +1,238 @@
/**
* useSSE Hook
*
* Wrapper around SSEContext that collects and manages events.
* Provides a clean interface for subscribing to SSE events with channel filtering.
*
* Examples:
* const { events } = useSSE(); // All events
* const { events } = useSSE({ channels: ['inventory.alerts'] });
* const { events } = useSSE({ channels: ['*.notifications'] });
*/
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';
interface UseSSEConfig {
channels?: string[];
}
interface UseSSEReturn {
events: Event[];
isConnected: boolean;
clearEvents: () => void;
}
const MAX_EVENTS = 200; // Keep last 200 events in memory
export function useSSEEvents(config: UseSSEConfig = {}): UseSSEReturn {
const context = useContext(SSEContext);
const [events, setEvents] = useState<Event[]>([]);
if (!context) {
throw new Error('useSSE must be used within SSEProvider');
}
// Create a stable key for the config channels to avoid unnecessary re-renders
// Use JSON.stringify for reliable comparison of channel arrays
const channelsKey = JSON.stringify(config.channels || []);
useEffect(() => {
const unsubscribers: (() => void)[] = [];
// Listen to 'alert' events (can be Alert or legacy format)
const handleAlert = (data: any) => {
console.log('🟢 [useSSE] handleAlert triggered', { data });
let event: Event;
// Check if it's new format (has event_class) or legacy format
if (data.event_class === 'alert' || data.event_class === 'notification' || data.event_class === 'recommendation') {
// New format
event = data as Event;
} else if (data.item_type === 'alert' || data.item_type === 'recommendation') {
// Legacy format - convert
event = convertLegacyAlert(data);
} else {
// Assume it's an alert if no clear classification
event = { ...data, event_class: 'alert', event_domain: 'operations' } as Alert;
}
console.log('🟢 [useSSE] Setting events state with new alert', {
eventId: event.id,
eventClass: event.event_class,
eventDomain: event.event_domain,
});
setEvents(prev => {
// Check if this event already exists to prevent duplicate processing
const existingIndex = prev.findIndex(e => e.id === event.id);
if (existingIndex !== -1) {
// Update existing event instead of adding duplicate
const newEvents = [...prev];
newEvents[existingIndex] = event;
return newEvents.slice(0, MAX_EVENTS);
}
// Add new event if not duplicate
const filtered = prev.filter(e => e.id !== event.id);
const newEvents = [event, ...filtered].slice(0, MAX_EVENTS);
console.log('🟢 [useSSE] Events array updated', {
prevCount: prev.length,
newCount: newEvents.length,
newEventIds: newEvents.map(e => e.id).join(','),
});
return newEvents;
});
};
unsubscribers.push(context.addEventListener('alert', handleAlert));
// Listen to 'notification' events
const handleNotification = (data: Notification) => {
setEvents(prev => {
// Check if this notification already exists to prevent duplicate processing
const existingIndex = prev.findIndex(e => e.id === data.id);
if (existingIndex !== -1) {
// Update existing notification instead of adding duplicate
const newEvents = [...prev];
newEvents[existingIndex] = data;
return newEvents.slice(0, MAX_EVENTS);
}
// Add new notification if not duplicate
const filtered = prev.filter(e => e.id !== data.id);
return [data, ...filtered].slice(0, MAX_EVENTS);
});
};
unsubscribers.push(context.addEventListener('notification', handleNotification));
// Listen to 'recommendation' events
const handleRecommendation = (data: any) => {
let event: Recommendation;
// Handle both new and legacy formats
if (data.event_class === 'recommendation') {
event = data as Recommendation;
} else if (data.item_type === 'recommendation') {
event = convertLegacyAlert(data) as Recommendation;
} else {
event = { ...data, event_class: 'recommendation', event_domain: 'operations' } as Recommendation;
}
setEvents(prev => {
// Check if this recommendation already exists to prevent duplicate processing
const existingIndex = prev.findIndex(e => e.id === event.id);
if (existingIndex !== -1) {
// Update existing recommendation instead of adding duplicate
const newEvents = [...prev];
newEvents[existingIndex] = event;
return newEvents.slice(0, MAX_EVENTS);
}
// Add new recommendation if not duplicate
const filtered = prev.filter(e => e.id !== event.id);
return [event, ...filtered].slice(0, MAX_EVENTS);
});
};
unsubscribers.push(context.addEventListener('recommendation', handleRecommendation));
// Listen to 'initial_state' event (batch load on connection)
const handleInitialState = (data: any) => {
if (Array.isArray(data)) {
// Convert each event to proper format
const initialEvents = data.map(item => {
if (item.event_class) {
return item as Event;
} else if (item.item_type) {
return convertLegacyAlert(item);
} else {
return { ...item, event_class: 'alert', event_domain: 'operations' } as Event;
}
});
setEvents(initialEvents.slice(0, MAX_EVENTS));
}
};
unsubscribers.push(context.addEventListener('initial_state', handleInitialState));
// Also listen to legacy 'initial_items' event
const handleInitialItems = (data: any) => {
if (Array.isArray(data)) {
const initialEvents = data.map(item => {
if (item.event_class) {
return item as Event;
} else {
return convertLegacyAlert(item);
}
});
setEvents(initialEvents.slice(0, MAX_EVENTS));
}
};
unsubscribers.push(context.addEventListener('initial_items', handleInitialItems));
return () => {
unsubscribers.forEach(unsub => unsub());
};
}, [context, channelsKey]); // Fixed: Added channelsKey dependency
const clearEvents = useCallback(() => {
setEvents([]);
}, []);
return {
events,
isConnected: context.isConnected,
clearEvents,
};
}
/**
* useSSEWithDedupe Hook
*
* Enhanced version that deduplicates events more aggressively
* based on event_type + entity_id for state change notifications.
*/
export function useSSEEventsWithDedupe(config: UseSSEConfig = {}) {
const { events: rawEvents, ...rest } = useSSEEvents(config);
const [deduplicatedEvents, setDeduplicatedEvents] = useState<Event[]>([]);
useEffect(() => {
// Deduplicate notifications by event_type + entity_id
const seen = new Set<string>();
const deduplicated: Event[] = [];
for (const event of rawEvents) {
let key: string;
if (event.event_class === 'notification') {
const notification = event as Notification;
// Deduplicate by entity (keep only latest state)
if (notification.entity_type && notification.entity_id) {
key = `${notification.event_type}:${notification.entity_type}:${notification.entity_id}`;
} else {
key = event.id;
}
} else {
// For alerts and recommendations, use ID
key = event.id;
}
if (!seen.has(key)) {
seen.add(key);
deduplicated.push(event);
}
}
setDeduplicatedEvents(deduplicated);
}, [rawEvents]);
return {
events: deduplicatedEvents,
...rest,
};
}

View File

@@ -46,6 +46,7 @@
"last_run": "Last run", "last_run": "Last run",
"what_ai_did": "What AI did for you" "what_ai_did": "What AI did for you"
}, },
"no_reasoning_available": "No reasoning available",
"metrics": { "metrics": {
"hours": "{count, plural, =1 {# hour} other {# hours}}", "hours": "{count, plural, =1 {# hour} other {# hours}}",
"minutes": "{count, plural, =1 {# minute} other {# minutes}}", "minutes": "{count, plural, =1 {# minute} other {# minutes}}",
@@ -135,14 +136,14 @@
"trend": { "trend": {
"near_threshold": "Near threshold" "near_threshold": "Near threshold"
}, },
"action_preview": { "action_preview": {
"title": "Action Preview", "title": "Action Preview",
"outcome": "What will happen", "outcome": "What will happen",
"financial_impact": "Financial Impact", "financial_impact": "Financial Impact",
"affected_systems": "Affected Systems", "affected_systems": "Affected Systems",
"reversible": "This action can be undone", "reversible": "This action can be undone",
"not_reversible": "This action cannot be undone", "not_reversible": "This action cannot be undone",
"confidence": "AI Confidence: {{confidence}}%", "confidence": "AI Confidence: {confidence}%",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm Action" "confirm": "Confirm Action"
}, },
@@ -288,8 +289,8 @@
"message": "{{shift_name}} over-staffed by {{excess_percent}}%. Consider adjusting staff levels to reduce labor costs." "message": "{{shift_name}} over-staffed by {{excess_percent}}%. Consider adjusting staff levels to reduce labor costs."
}, },
"po_approval_needed": { "po_approval_needed": {
"title": "Purchase Order #{{po_number}} requires approval", "title": "Purchase Order #{po_number} requires approval",
"message": "Purchase order for {{supplier_name}} totaling {{currency}} {{total_amount}} is pending approval. Delivery required by {{required_delivery_date}}." "message": "Purchase order for {supplier_name} totaling {currency} {total_amount} is pending approval. Delivery required by {required_delivery_date}."
}, },
"production_batch_start": { "production_batch_start": {
"title": "Production Batch Ready: {{product_name}}", "title": "Production Batch Ready: {{product_name}}",

View File

@@ -65,8 +65,11 @@
"continue": "Continue", "continue": "Continue",
"confirm": "Confirm", "confirm": "Confirm",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse" "collapse": "Collapse",
"save_draft": "Save Draft",
"confirm_receipt": "Confirm Receipt"
}, },
"saved": "saved",
"item": "item", "item": "item",
"items": "items", "items": "items",
"unknown": "Unknown", "unknown": "Unknown",
@@ -317,10 +320,6 @@
"profile_menu": "Profile menu", "profile_menu": "Profile menu",
"close_navigation": "Close navigation" "close_navigation": "Close navigation"
}, },
"header": {
"main_navigation": "Main navigation",
"open_menu": "Open navigation menu"
},
"footer": { "footer": {
"company_description": "Intelligent management system for bakeries. Optimize your production, inventory and sales with artificial intelligence.", "company_description": "Intelligent management system for bakeries. Optimize your production, inventory and sales with artificial intelligence.",
"sections": { "sections": {
@@ -413,9 +412,14 @@
"minimize": "Minimize", "minimize": "Minimize",
"accept_all": "Accept All", "accept_all": "Accept All",
"accept_essential": "Essential Only", "accept_essential": "Essential Only",
"customize": "Customize" "customize": "Customize",
"save_draft": "Save Draft",
"confirm_receipt": "Confirm Receipt"
}, },
"header": { "header": {
"main_navigation": "Main navigation",
"notifications": "Notifications",
"unread_count": "{{count}} unread notifications",
"login": "Login", "login": "Login",
"start_free": "Start Free", "start_free": "Start Free",
"register": "Sign Up", "register": "Sign Up",

View File

@@ -171,10 +171,15 @@
"health": { "health": {
"production_on_schedule": "Production on schedule", "production_on_schedule": "Production on schedule",
"production_delayed": "{count} production batch{count, plural, one {} other {es}} delayed", "production_delayed": "{count} production batch{count, plural, one {} other {es}} delayed",
"production_ai_prevented": "AI prevented {count} production delay{count, plural, one {} other {s}}",
"all_ingredients_in_stock": "All ingredients in stock", "all_ingredients_in_stock": "All ingredients in stock",
"ingredients_out_of_stock": "{count} ingredient{count, plural, one {} other {s}} out of stock", "ingredients_out_of_stock": "{count} ingredient{count, plural, one {} other {s}} out of stock",
"inventory_ai_prevented": "AI prevented {count} inventory issue{count, plural, one {} other {s}}",
"no_pending_approvals": "No pending approvals", "no_pending_approvals": "No pending approvals",
"approvals_awaiting": "{count} purchase order{count, plural, one {} other {s}} awaiting approval", "approvals_awaiting": "{count} purchase order{count, plural, one {} other {s}} awaiting approval",
"procurement_ai_created": "AI created {count} purchase order{count, plural, one {} other {s}} automatically",
"deliveries_on_track": "All deliveries on track",
"deliveries_pending": "{count} pending deliver{count, plural, one {y} other {ies}}",
"all_systems_operational": "All systems operational", "all_systems_operational": "All systems operational",
"critical_issues": "{count} critical issue{count, plural, one {} other {s}}", "critical_issues": "{count} critical issue{count, plural, one {} other {s}}",
"headline_green": "Your bakery is running smoothly", "headline_green": "Your bakery is running smoothly",
@@ -235,10 +240,94 @@
"complete_setup": "Complete Setup" "complete_setup": "Complete Setup"
} }
}, },
"action_queue_title": "Action Queue",
"total_actions": "actions",
"all_caught_up": "All Caught Up!",
"no_actions_needed": "No actions needed right now",
"orchestration": { "orchestration": {
"no_runs_message": "No orchestration has been run yet. Click 'Run Daily Planning' to generate your first plan." "no_runs_message": "No orchestration has been run yet. Click 'Run Daily Planning' to generate your first plan.",
"reasoning_title": "AI Orchestration Reasoning",
"no_data": "No orchestration data available yet. The AI will analyze your operations and make recommendations.",
"last_run": "Last run",
"ai_automated": "AI Automated",
"actions_completed": "actions completed",
"user_needed": "User Needed",
"needs_review": "needs your review",
"all_handled": "all handled by AI",
"prevented_badge": "{{count}} issue{{count, plural, one {} other {s}}} prevented",
"prevented_description": "AI proactively handled these before they became problems",
"analyzed_title": "What I Analyzed",
"actions_taken": "What I Did",
"prevented_issues": "Issues I Prevented",
"prevented_issues_detail": "Issue Prevention Details",
"estimated_impact": "Estimated Impact",
"impact_description": "Based on prevented stockouts, waste reduction, and optimized purchasing"
}, },
"errors": { "errors": {
"failed_to_load_stats": "Failed to load dashboard statistics. Please try again." "failed_to_load_stats": "Failed to load dashboard statistics. Please try again."
},
"ai_handling_rate": {
"title": "AI Impact This Week",
"subtitle": "Issues prevented before they became problems",
"handling_rate_label": "of alerts handled automatically",
"prevented_count_label": "Prevented",
"issues_text": "issues",
"savings_label": "Savings",
"estimated_text": "estimated",
"context": "Based on {total} total alerts over the last {days} days.",
"view_prevented_issues": "View Prevented Issues",
"no_prevented_issues": "No issues prevented this week - all systems running smoothly!",
"error_title": "Unable to load AI metrics",
"error_message": "Please try again later"
},
"prevented_issues": {
"title": "Prevented Issues",
"subtitle": "AI interventions this week",
"total_savings": "Total Saved",
"celebration": "Great news! AI prevented {count} issue{plural} before they became problems.",
"ai_insight": "AI Insight:",
"show_less": "Show Less",
"show_more": "Show {{count}} More",
"no_issues": "No issues prevented this week",
"no_issues_detail": "All systems running smoothly!",
"error_title": "Unable to load prevented issues"
},
"execution_progress": {
"title": "Today's Execution Progress",
"subtitle": "Plan vs Actual",
"production": "Production",
"deliveries": "Deliveries",
"approvals": "Approvals",
"status": {
"no_plan": "No Plan",
"no_deliveries": "No Deliveries",
"completed": "Completed",
"on_track": "On Track",
"at_risk": "At Risk"
},
"no_production_plan": "No production planned for today",
"no_deliveries_today": "No deliveries scheduled for today",
"batches_complete": "batches complete",
"completed": "Completed",
"in_progress": "In Progress",
"pending": "Pending",
"received": "Received",
"overdue": "Overdue",
"pending_approvals": "Pending Approvals",
"whats_next": "What's Next",
"starts_at": "starts at"
},
"intelligent_system": {
"title": "Intelligent System Summary",
"subtitle": "AI-powered automation working for you",
"ai_handling_rate": "AI Handling Rate",
"prevented_issues": "Issues Prevented",
"estimated_savings": "Estimated Savings",
"context": "Based on {total} alerts over the last {days} days",
"prevented_issues_details": "Prevented Issues Details",
"no_prevented_issues": "No issues prevented this week - all systems running smoothly!",
"celebration": "Great news! AI prevented {count} issue(s) before they became problems.",
"ai_insight": "AI Insight:",
"orchestration_title": "Latest Orchestration Run"
} }
} }

View File

@@ -114,5 +114,36 @@
"INITIAL_STOCK": "Initial Stock", "INITIAL_STOCK": "Initial Stock",
"OTHER": "Other" "OTHER": "Other"
} }
},
"stock_receipt_title": "Stock Receipt Confirmation",
"purchase_order": "Purchase Order",
"supplier_info": "Supplier Information",
"expected": "Expected",
"actual": "Actual",
"discrepancy_detected": "Discrepancy Detected",
"lot_total": "Lot Total",
"discrepancy_reason": "Discrepancy Reason",
"discrepancy_reason_placeholder": "Explain why the received quantity differs from expected...",
"lots": "Lots",
"lot_details": "Lot Details",
"quantity": "Quantity",
"supplier_lot_number": "Supplier Lot Number",
"warehouse_location": "Warehouse Location",
"storage_zone": "Storage Zone",
"quality_notes": "Quality Notes",
"quality_notes_placeholder": "Any quality observations or special handling notes...",
"add_lot": "Add Another Lot",
"receipt_notes": "Receipt Notes",
"receipt_notes_placeholder": "General notes about this delivery...",
"validation": {
"no_line_items": "At least one line item is required",
"at_least_one_lot": "At least one lot is required for each line item",
"lot_quantity_mismatch": "Lot quantities ({{actual}}) must sum to actual quantity ({{expected}})",
"expiration_required": "Expiration date is required",
"quantity_required": "Quantity must be greater than 0"
},
"errors": {
"save_failed": "Failed to save draft. Please try again.",
"confirm_failed": "Failed to confirm receipt. Please check all fields and try again."
} }
} }

View File

@@ -73,6 +73,10 @@
"green": "Everything is running smoothly", "green": "Everything is running smoothly",
"yellow": "Some items need attention", "yellow": "Some items need attention",
"red": "Critical issues require immediate action", "red": "Critical issues require immediate action",
"green_simple": "✅ Everything is ready for today",
"yellow_simple": "⚠️ Some items need attention",
"red_simple": "🔴 Critical issues require action",
"yellow_simple_with_count": "⚠️ {count} action{count, plural, one {} other {s}} needed",
"last_updated": "Last updated", "last_updated": "Last updated",
"next_check": "Next check", "next_check": "Next check",
"never": "Never", "never": "Never",

View File

@@ -46,6 +46,7 @@
"last_run": "Última ejecución", "last_run": "Última ejecución",
"what_ai_did": "Lo que hizo la IA por ti" "what_ai_did": "Lo que hizo la IA por ti"
}, },
"no_reasoning_available": "No hay razonamiento disponible",
"metrics": { "metrics": {
"hours": "{count, plural, =1 {# hora} other {# horas}}", "hours": "{count, plural, =1 {# hora} other {# horas}}",
"minutes": "{count, plural, =1 {# minuto} other {# minutos}}", "minutes": "{count, plural, =1 {# minuto} other {# minutos}}",
@@ -140,7 +141,7 @@
"affected_systems": "Sistemas Afectados", "affected_systems": "Sistemas Afectados",
"reversible": "Esta acción se puede deshacer", "reversible": "Esta acción se puede deshacer",
"not_reversible": "Esta acción no se puede deshacer", "not_reversible": "Esta acción no se puede deshacer",
"confidence": "Confianza de IA: {{confidence}}%", "confidence": "Confianza de IA: {confidence}%",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar Acción" "confirm": "Confirmar Acción"
}, },
@@ -161,137 +162,137 @@
}, },
"alerts": { "alerts": {
"critical_stock_shortage": { "critical_stock_shortage": {
"title": "🚨 Stock Crítico: {{ingredient_name}}", "title": "🚨 Stock Crítico: {ingredient_name}",
"message_with_po_pending": "Solo {{current_stock}}kg de {{ingredient_name}} (necesitas {{required_stock}}kg). Ya creé {{po_id}} para entrega el {{delivery_day_name}}. Por favor aprueba €{{po_amount}}.", "message_with_po_pending": "Solo {current_stock}kg de {ingredient_name} (necesitas {required_stock}kg). Ya creé {po_id} para entrega el {delivery_day_name}. Por favor aprueba €{po_amount}.",
"message_with_po_created": "Solo {{current_stock}}kg de {{ingredient_name}} (necesitas {{required_stock}}kg). Ya creé {{po_id}}. Revisa y aprueba €{{po_amount}}.", "message_with_po_created": "Solo {current_stock}kg de {ingredient_name} (necesitas {required_stock}kg). Ya creé {po_id}. Revisa y aprueba €{po_amount}.",
"message_with_hours": "Solo {{current_stock}}kg de {{ingredient_name}} disponibles (necesitas {{required_stock}}kg en {{hours_until}} horas).", "message_with_hours": "Solo {current_stock}kg de {ingredient_name} disponibles (necesitas {required_stock}kg en {hours_until} horas).",
"message_with_date": "Solo {{current_stock}}kg de {{ingredient_name}} disponibles (necesitas {{required_stock}}kg para producción del {{production_day_name}}).", "message_with_date": "Solo {current_stock}kg de {ingredient_name} disponibles (necesitas {required_stock}kg para producción del {production_day_name}).",
"message_generic": "Solo {{current_stock}}kg de {{ingredient_name}} disponibles (necesitas {{required_stock}}kg)." "message_generic": "Solo {current_stock}kg de {ingredient_name} disponibles (necesitas {required_stock}kg)."
}, },
"low_stock": { "low_stock": {
"title": "⚠️ Stock Bajo: {{ingredient_name}}", "title": "⚠️ Stock Bajo: {ingredient_name}",
"message_with_po": "Stock de {{ingredient_name}}: {{current_stock}}kg (mínimo: {{minimum_stock}}kg). Ya programé PO para reposición.", "message_with_po": "Stock de {ingredient_name}: {current_stock}kg (mínimo: {minimum_stock}kg). Ya programé PO para reposición.",
"message_generic": "Stock de {{ingredient_name}}: {{current_stock}}kg (mínimo: {{minimum_stock}}kg). Considera hacer un pedido." "message_generic": "Stock de {ingredient_name}: {current_stock}kg (mínimo: {minimum_stock}kg). Considera hacer un pedido."
}, },
"stock_depleted": { "stock_depleted": {
"title": "📦 Stock Agotado por Pedido", "title": "📦 Stock Agotado por Pedido",
"message_with_supplier": "Pedido #{{order_id}} requiere {{ingredient_name}} pero está agotado. Contacta a {{supplier_name}} ({{supplier_phone}}).", "message_with_supplier": "Pedido #{order_id} requiere {ingredient_name} pero está agotado. Contacta a {supplier_name} ({supplier_phone}).",
"message_generic": "Pedido #{{order_id}} requiere {{ingredient_name}} pero está agotado. Acción inmediata requerida." "message_generic": "Pedido #{order_id} requiere {ingredient_name} pero está agotado. Acción inmediata requerida."
}, },
"ingredient_shortage": { "ingredient_shortage": {
"title": "⚠️ Falta de Ingrediente en Producción", "title": "⚠️ Falta de Ingrediente en Producción",
"message_with_customers": "{{ingredient_name}} insuficiente para lotes en curso. {{affected_orders}} pedidos afectados ({{customer_names}}). Stock actual: {{current_stock}}kg, necesario: {{required_stock}}kg.", "message_with_customers": "{ingredient_name} insuficiente para lotes en curso. {affected_orders} pedidos afectados ({customer_names}). Stock actual: {current_stock}kg, necesario: {required_stock}kg.",
"message_generic": "{{ingredient_name}} insuficiente para lotes en curso. Stock actual: {{current_stock}}kg, necesario: {{required_stock}}kg." "message_generic": "{ingredient_name} insuficiente para lotes en curso. Stock actual: {current_stock}kg, necesario: {required_stock}kg."
}, },
"expired_products": { "expired_products": {
"title": "🚫 Productos Caducados o por Caducar", "title": "🚫 Productos Caducados o por Caducar",
"message_with_names": "{{count}} productos caducando: {{product_names}}. Valor total: €{{total_value}}.", "message_with_names": "{count} productos caducando: {product_names}. Valor total: €{total_value}.",
"message_generic": "{{count}} productos caducando. Valor total: €{{total_value}}." "message_generic": "{count} productos caducando. Valor total: €{total_value}."
}, },
"production_delay": { "production_delay": {
"title": "⏰ Retraso en Producción: {{batch_name}}", "title": "⏰ Retraso en Producción: {batch_name}",
"message_with_customers": "Lote {{batch_name}} retrasado {{delay_minutes}} minutos. Clientes afectados: {{customer_names}}. Hora entrega original: {{scheduled_time}}.", "message_with_customers": "Lote {batch_name} retrasado {delay_minutes} minutos. Clientes afectados: {customer_names}. Hora entrega original: {scheduled_time}.",
"message_with_orders": "Lote {{batch_name}} retrasado {{delay_minutes}} minutos. {{affected_orders}} pedidos afectados. Hora entrega original: {{scheduled_time}}.", "message_with_orders": "Lote {batch_name} retrasado {delay_minutes} minutos. {affected_orders} pedidos afectados. Hora entrega original: {scheduled_time}.",
"message_generic": "Lote {{batch_name}} retrasado {{delay_minutes}} minutos. Hora entrega original: {{scheduled_time}}." "message_generic": "Lote {batch_name} retrasado {delay_minutes} minutos. Hora entrega original: {scheduled_time}."
}, },
"equipment_failure": { "equipment_failure": {
"title": "🔧 Fallo de Equipo: {{equipment_name}}", "title": "🔧 Fallo de Equipo: {equipment_name}",
"message_with_batches": "{{equipment_name}} falló. {{affected_batches}} lotes en producción afectados ({{batch_names}}).", "message_with_batches": "{equipment_name} falló. {affected_batches} lotes en producción afectados ({batch_names}).",
"message_generic": "{{equipment_name}} falló. Requiere reparación inmediata." "message_generic": "{equipment_name} falló. Requiere reparación inmediata."
}, },
"maintenance_required": { "maintenance_required": {
"title": "🔧 Mantenimiento Requerido: {{equipment_name}}", "title": "🔧 Mantenimiento Requerido: {equipment_name}",
"message_with_hours": "{{equipment_name}} requiere mantenimiento en {{hours_until}} horas. Programa ahora para evitar interrupciones.", "message_with_hours": "{equipment_name} requiere mantenimiento en {hours_until} horas. Programa ahora para evitar interrupciones.",
"message_with_days": "{{equipment_name}} requiere mantenimiento en {{days_until}} días. Programa antes del {{maintenance_date}}." "message_with_days": "{equipment_name} requiere mantenimiento en {days_until} días. Programa antes del {maintenance_date}."
}, },
"low_equipment_efficiency": { "low_equipment_efficiency": {
"title": "📉 Baja Eficiencia: {{equipment_name}}", "title": "📉 Baja Eficiencia: {equipment_name}",
"message": "{{equipment_name}} operando a {{efficiency_percent}}% eficiencia (esperado: >{{threshold_percent}}%). Considera mantenimiento." "message": "{equipment_name} operando a {efficiency_percent}% eficiencia (esperado: >{threshold_percent}%). Considera mantenimiento."
}, },
"order_overload": { "order_overload": {
"title": "📊 Sobrecarga de Pedidos", "title": "📊 Sobrecarga de Pedidos",
"message_with_orders": "Capacidad sobrecargada en {{overload_percent}}%. {{total_orders}} pedidos programados, capacidad: {{capacity_orders}}. Considera ajustar el calendario.", "message_with_orders": "Capacidad sobrecargada en {overload_percent}%. {total_orders} pedidos programados, capacidad: {capacity_orders}. Considera ajustar el calendario.",
"message_generic": "Capacidad sobrecargada en {{overload_percent}}%. Considera ajustar el calendario de producción." "message_generic": "Capacidad sobrecargada en {overload_percent}%. Considera ajustar el calendario de producción."
}, },
"supplier_delay": { "supplier_delay": {
"title": "🚚 Retraso de Proveedor: {{supplier_name}}", "title": "🚚 Retraso de Proveedor: {supplier_name}",
"message": "{{supplier_name}} retrasó entrega de {{ingredient_name}} (PO: {{po_id}}). Nueva fecha: {{new_delivery_date}}. Original: {{original_delivery_date}}." "message": "{supplier_name} retrasó entrega de {ingredient_name} (PO: {po_id}). Nueva fecha: {new_delivery_date}. Original: {original_delivery_date}."
}, },
"temperature_breach": { "temperature_breach": {
"title": "🌡️ Violación de Temperatura", "title": "🌡️ Violación de Temperatura",
"message": "Temperatura {{location}}: {{current_temp}}°C (rango: {{min_temp}}°C-{{max_temp}}°C). Duración: {{duration_minutes}} minutos." "message": "Temperatura {location}: {current_temp}°C (rango: {min_temp}°C-{max_temp}°C). Duración: {duration_minutes} minutos."
}, },
"demand_surge_weekend": { "demand_surge_weekend": {
"title": "📈 Aumento de Demanda: Fin de Semana", "title": "📈 Aumento de Demanda: Fin de Semana",
"message": "Demanda esperada {{surge_percent}}% mayor para {{weekend_date}}. Productos afectados: {{products}}. Considera aumentar producción." "message": "Demanda esperada {surge_percent}% mayor para {weekend_date}. Productos afectados: {products}. Considera aumentar producción."
}, },
"weather_impact_alert": { "weather_impact_alert": {
"title": "🌦️ Impacto Climático Esperado", "title": "🌦️ Impacto Climático Esperado",
"message": "{{weather_condition}} esperado {{date}}. Impacto en demanda: {{impact_percent}}%. Productos afectados: {{products}}." "message": "{weather_condition} esperado {date}. Impacto en demanda: {impact_percent}%. Productos afectados: {products}."
}, },
"holiday_preparation": { "holiday_preparation": {
"title": "🎉 Preparación para Festivo: {{holiday_name}}", "title": "🎉 Preparación para Festivo: {holiday_name}",
"message": "{{holiday_name}} en {{days_until}} días ({{holiday_date}}). Demanda esperada {{expected_increase}}% mayor. Productos clave: {{products}}." "message": "{holiday_name} en {days_until} días ({holiday_date}). Demanda esperada {expected_increase}% mayor. Productos clave: {products}."
}, },
"severe_weather_impact": { "severe_weather_impact": {
"title": "⛈️ Impacto Climático Severo", "title": "⛈️ Impacto Climático Severo",
"message": "{{weather_condition}} severo esperado {{date}}. Impacto en demanda: {{impact_percent}}%. Considera ajustar horarios de entrega." "message": "{weather_condition} severo esperado {date}. Impacto en demanda: {impact_percent}%. Considera ajustar horarios de entrega."
}, },
"unexpected_demand_spike": { "unexpected_demand_spike": {
"title": "📊 Pico de Demanda Inesperado", "title": "📊 Pico de Demanda Inesperado",
"message": "Demanda aumentó {{spike_percent}}% para {{products}}. Stock actual podría agotarse en {{hours_until_stockout}} horas." "message": "Demanda aumentó {spike_percent}% para {products}. Stock actual podría agotarse en {hours_until_stockout} horas."
}, },
"demand_pattern_optimization": { "demand_pattern_optimization": {
"title": "💡 Optimización de Patrón de Demanda", "title": "💡 Optimización de Patrón de Demanda",
"message": "Patrón detectado: {{pattern_description}}. Recomienda ajustar producción de {{products}} para optimizar eficiencia." "message": "Patrón detectado: {pattern_description}. Recomienda ajustar producción de {products} para optimizar eficiencia."
}, },
"inventory_optimization": { "inventory_optimization": {
"title": "📦 Optimización de Inventario", "title": "📦 Optimización de Inventario",
"message": "{{ingredient_name}} consistentemente sobre-stock por {{excess_percent}}%. Recomienda reducir pedido a {{recommended_amount}}kg." "message": "{ingredient_name} consistentemente sobre-stock por {excess_percent}%. Recomienda reducir pedido a {recommended_amount}kg."
}, },
"production_efficiency": { "production_efficiency": {
"title": "⚡ Oportunidad de Eficiencia en Producción", "title": "⚡ Oportunidad de Eficiencia en Producción",
"message": "{{product_name}} más eficiente a las {{suggested_time}}. Tiempo de producción {{time_saved}} minutos menor ({{savings_percent}}% ahorro)." "message": "{product_name} más eficiente a las {suggested_time}. Tiempo de producción {time_saved} minutos menor ({savings_percent}% ahorro)."
}, },
"sales_opportunity": { "sales_opportunity": {
"title": "💰 Oportunidad de Ventas", "title": "💰 Oportunidad de Ventas",
"message": "Alta demanda de {{products}} detectada. Considera producción adicional. Ingresos potenciales: €{{potential_revenue}}." "message": "Alta demanda de {products} detectada. Considera producción adicional. Ingresos potenciales: €{potential_revenue}."
}, },
"seasonal_adjustment": { "seasonal_adjustment": {
"title": "🍂 Ajuste Estacional Recomendado", "title": "🍂 Ajuste Estacional Recomendado",
"message": "Temporada {{season}} acercándose. Ajusta producción de {{products}} basado en tendencias históricas ({{adjustment_percent}}% {{adjustment_direction}})." "message": "Temporada {season} acercándose. Ajusta producción de {products} basado en tendencias históricas ({adjustment_percent}% {adjustment_direction})."
}, },
"cost_reduction": { "cost_reduction": {
"title": "💵 Oportunidad de Reducción de Costos", "title": "💵 Oportunidad de Reducción de Costos",
"message": "Cambiando a {{alternative_ingredient}} puede ahorrar €{{savings_amount}}/mes. Calidad similar, menor costo." "message": "Cambiando a {alternative_ingredient} puede ahorrar €{savings_amount}/mes. Calidad similar, menor costo."
}, },
"waste_reduction": { "waste_reduction": {
"title": "♻️ Oportunidad de Reducción de Desperdicio", "title": "♻️ Oportunidad de Reducción de Desperdicio",
"message": "{{product_name}} desperdicio alto ({{waste_percent}}%). Recomienda reducir lote a {{recommended_quantity}} unidades." "message": "{product_name} desperdicio alto ({waste_percent}%). Recomienda reducir lote a {recommended_quantity} unidades."
}, },
"quality_improvement": { "quality_improvement": {
"title": "⭐ Mejora de Calidad Recomendada", "title": "⭐ Mejora de Calidad Recomendada",
"message": "{{issue_description}} detectado en {{product_name}}. Acción recomendada: {{recommended_action}}." "message": "{issue_description} detectado en {product_name}. Acción recomendada: {recommended_action}."
}, },
"customer_satisfaction": { "customer_satisfaction": {
"title": "😊 Oportunidad de Satisfacción del Cliente", "title": "😊 Oportunidad de Satisfacción del Cliente",
"message": "Cliente {{customer_name}} consistentemente ordena {{product_name}}. Considera oferta especial o programa de lealtad." "message": "Cliente {customer_name} consistentemente ordena {product_name}. Considera oferta especial o programa de lealtad."
}, },
"energy_optimization": { "energy_optimization": {
"title": "⚡ Optimización de Energía", "title": "⚡ Optimización de Energía",
"message": "Uso de energía para {{equipment_name}} pico a las {{peak_time}}. Cambiando a {{off_peak_start}}-{{off_peak_end}} puede ahorrar €{{savings_amount}}/mes." "message": "Uso de energía para {equipment_name} pico a las {peak_time}. Cambiando a {off_peak_start}-{off_peak_end} puede ahorrar €{savings_amount}/mes."
}, },
"staff_optimization": { "staff_optimization": {
"title": "👥 Optimización de Personal", "title": "👥 Optimización de Personal",
"message": "{{shift_name}} sobrestimado por {{excess_percent}}%. Considera ajustar niveles de personal para reducir costos laborales." "message": "{shift_name} sobrestimado por {excess_percent}%. Considera ajustar niveles de personal para reducir costos laborales."
}, },
"po_approval_needed": { "po_approval_needed": {
"title": "Orden de Compra #{{po_number}} requiere aprobación", "title": "Orden de Compra #{po_number} requiere aprobación",
"message": "Orden de compra para {{supplier_name}} por un total de {{currency}} {{total_amount}} pendiente de aprobación. Entrega requerida para {{required_delivery_date}}." "message": "Orden de compra para {supplier_name} por un total de {currency} {total_amount} pendiente de aprobación. Entrega requerida para {required_delivery_date}."
}, },
"production_batch_start": { "production_batch_start": {
"title": "Lote de Producción Listo: {{product_name}}", "title": "Lote de Producción Listo: {product_name}",
"message": "Lote #{{batch_number}} ({{quantity_planned}} {{unit}} de {{product_name}}) está listo para comenzar. Prioridad: {{priority}}." "message": "Lote #{batch_number} ({quantity_planned} {unit} de {product_name}) está listo para comenzar. Prioridad: {priority}."
} }
} }
} }

View File

@@ -65,8 +65,11 @@
"continue": "Continuar", "continue": "Continuar",
"confirm": "Confirmar", "confirm": "Confirmar",
"expand": "Expandir", "expand": "Expandir",
"collapse": "Contraer" "collapse": "Contraer",
"save_draft": "Guardar Borrador",
"confirm_receipt": "Confirmar Recepción"
}, },
"saved": "ahorrado",
"item": "artículo", "item": "artículo",
"items": "artículos", "items": "artículos",
"unknown": "Desconocido", "unknown": "Desconocido",
@@ -341,10 +344,6 @@
"profile_menu": "Menú de perfil", "profile_menu": "Menú de perfil",
"close_navigation": "Cerrar navegación" "close_navigation": "Cerrar navegación"
}, },
"header": {
"main_navigation": "Navegación principal",
"open_menu": "Abrir menú de navegación"
},
"footer": { "footer": {
"company_description": "Sistema inteligente de gestión para panaderías. Optimiza tu producción, inventario y ventas con inteligencia artificial.", "company_description": "Sistema inteligente de gestión para panaderías. Optimiza tu producción, inventario y ventas con inteligencia artificial.",
"sections": { "sections": {
@@ -440,6 +439,9 @@
"customize": "Personalizar" "customize": "Personalizar"
}, },
"header": { "header": {
"main_navigation": "Navegación principal",
"notifications": "Notificaciones",
"unread_count": "{{count}} notificaciones sin leer",
"login": "Iniciar Sesión", "login": "Iniciar Sesión",
"start_free": "Comenzar Gratis", "start_free": "Comenzar Gratis",
"register": "Registro", "register": "Registro",

View File

@@ -107,12 +107,6 @@
"hours_ago": "hace {{count}} h", "hours_ago": "hace {{count}} h",
"yesterday": "Ayer" "yesterday": "Ayer"
}, },
"severity": {
"urgent": "Urgente",
"high": "Alta",
"medium": "Media",
"low": "Baja"
},
"types": { "types": {
"alert": "Alerta", "alert": "Alerta",
"recommendation": "Recomendación", "recommendation": "Recomendación",
@@ -134,8 +128,6 @@
}, },
"filters": { "filters": {
"search_placeholder": "Buscar alertas...", "search_placeholder": "Buscar alertas...",
"severity": "Severidad",
"category": "Categoría",
"time_range": "Periodo", "time_range": "Periodo",
"show_snoozed": "Mostrar pospuestos", "show_snoozed": "Mostrar pospuestos",
"active_filters": "Filtros activos:", "active_filters": "Filtros activos:",
@@ -143,7 +135,7 @@
}, },
"grouping": { "grouping": {
"by_time": "Por tiempo", "by_time": "Por tiempo",
"by_category": "Por categoría", "by_type_class": "Por tipo",
"by_similarity": "Similares", "by_similarity": "Similares",
"none": "Sin agrupar" "none": "Sin agrupar"
}, },
@@ -206,10 +198,15 @@
"health": { "health": {
"production_on_schedule": "Producción a tiempo", "production_on_schedule": "Producción a tiempo",
"production_delayed": "{count} lote{count, plural, one {} other {s}} de producción retrasado{count, plural, one {} other {s}}", "production_delayed": "{count} lote{count, plural, one {} other {s}} de producción retrasado{count, plural, one {} other {s}}",
"production_ai_prevented": "IA evitó {count} retraso{count, plural, one {} other {s}} de producción",
"all_ingredients_in_stock": "Todos los ingredientes en stock", "all_ingredients_in_stock": "Todos los ingredientes en stock",
"ingredients_out_of_stock": "{count} ingrediente{count, plural, one {} other {s}} sin stock", "ingredients_out_of_stock": "{count} ingrediente{count, plural, one {} other {s}} sin stock",
"inventory_ai_prevented": "IA evitó {count} problema{count, plural, one {} other {s}} de inventario",
"no_pending_approvals": "Sin aprobaciones pendientes", "no_pending_approvals": "Sin aprobaciones pendientes",
"approvals_awaiting": "{count} orden{count, plural, one {} other {es}} de compra esperando aprobación", "approvals_awaiting": "{count} orden{count, plural, one {} other {es}} de compra esperando aprobación",
"procurement_ai_created": "IA creó {count} orden{count, plural, one {} other {es}} de compra automáticamente",
"deliveries_on_track": "Todas las entregas a tiempo",
"deliveries_pending": "{count} entrega{count, plural, one {} other {s}} pendiente{count, plural, one {} other {s}}",
"all_systems_operational": "Todos los sistemas operativos", "all_systems_operational": "Todos los sistemas operativos",
"critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}", "critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}",
"headline_green": "Tu panadería funciona sin problemas", "headline_green": "Tu panadería funciona sin problemas",
@@ -251,6 +248,28 @@
"cost_analysis": "Análisis de Costos" "cost_analysis": "Análisis de Costos"
} }
}, },
"setup_banner": {
"title": "{{count}} paso(s) más para desbloquear todas las funciones",
"recommended": "(recomendado)",
"added": "agregado(s)",
"recommended_count": "Recomendado",
"dismiss": "Ocultar por 7 días",
"dismiss_info": "Puedes ocultar este banner por 7 días haciendo clic en la X",
"benefits_title": "✨ Al completar estos pasos, desbloquearás",
"benefit_1": "Análisis de costos más preciso",
"benefit_2": "Recomendaciones de IA mejoradas",
"benefit_3": "Planificación de producción optimizada"
},
"setup_wizard": {
"title": "🏗️ Configuración Necesaria",
"subtitle": "Completa estos pasos para empezar a usar tu panadería",
"progress": "Progreso",
"step": "Paso",
"of": "de",
"next": "SIGUIENTE",
"complete_to_continue": "Completa para continuar",
"setup_blocked_message": "Necesitas completar la configuración básica antes de acceder al panel de control."
},
"action_queue": { "action_queue": {
"consequences": { "consequences": {
"delayed_delivery": "La entrega retrasada puede afectar el programa de producción", "delayed_delivery": "La entrega retrasada puede afectar el programa de producción",
@@ -270,10 +289,94 @@
"complete_setup": "Completar Configuración" "complete_setup": "Completar Configuración"
} }
}, },
"action_queue_title": "Cola de Acciones",
"total_actions": "acciones",
"all_caught_up": "¡Todo al Día!",
"no_actions_needed": "No se necesitan acciones ahora mismo",
"orchestration": { "orchestration": {
"no_runs_message": "Aún no se ha ejecutado ninguna orquestación. Haga clic en 'Ejecutar Planificación Diaria' para generar su primer plan." "no_runs_message": "Aún no se ha ejecutado ninguna orquestación. Haga clic en 'Ejecutar Planificación Diaria' para generar su primer plan.",
"reasoning_title": "Razonamiento de Orquestación de IA",
"no_data": "Aún no hay datos de orquestación disponibles. La IA analizará sus operaciones y hará recomendaciones.",
"last_run": "Última ejecución",
"ai_automated": "IA Automatizada",
"actions_completed": "acciones completadas",
"user_needed": "Usuario Necesario",
"needs_review": "necesita tu revisión",
"all_handled": "todo manejado por IA",
"prevented_badge": "{{count}} problema{{count, plural, one {} other {s}}} evitado{{count, plural, one {} other {s}}}",
"prevented_description": "La IA manejó estos proactivamente antes de que se convirtieran en problemas",
"analyzed_title": "Lo Que Analicé",
"actions_taken": "Lo Que Hice",
"prevented_issues": "Problemas Que Evité",
"prevented_issues_detail": "Detalles de Prevención de Problemas",
"estimated_impact": "Impacto Estimado",
"impact_description": "Basado en desabastecimientos evitados, reducción de desperdicios y compras optimizadas"
}, },
"errors": { "errors": {
"failed_to_load_stats": "Error al cargar las estadísticas del panel. Por favor, inténtelo de nuevo." "failed_to_load_stats": "Error al cargar las estadísticas del panel. Por favor, inténtelo de nuevo."
},
"ai_handling_rate": {
"title": "Impacto de IA Esta Semana",
"subtitle": "Problemas evitados antes de que se convirtieran en incidencias",
"handling_rate_label": "de alertas manejadas automáticamente",
"prevented_count_label": "Evitadas",
"issues_text": "incidencias",
"savings_label": "Ahorros",
"estimated_text": "estimados",
"context": "Basado en {total} alertas totales durante los últimos {days} días.",
"view_prevented_issues": "Ver Incidencias Evitadas",
"no_prevented_issues": "No se evitaron incidencias esta semana - ¡todos los sistemas funcionan correctamente!",
"error_title": "No se pueden cargar las métricas de IA",
"error_message": "Por favor, inténtelo de nuevo más tarde"
},
"prevented_issues": {
"title": "Incidencias Evitadas",
"subtitle": "Intervenciones de IA esta semana",
"total_savings": "Total Ahorrado",
"celebration": "¡Buenas noticias! La IA evitó {count} incidencia{plural} antes de que se convirtieran en problemas.",
"ai_insight": "Análisis de IA:",
"show_less": "Mostrar Menos",
"show_more": "Mostrar {{count}} Más",
"no_issues": "No se evitaron incidencias esta semana",
"no_issues_detail": "¡Todos los sistemas funcionan correctamente!",
"error_title": "No se pueden cargar las incidencias evitadas"
},
"execution_progress": {
"title": "Progreso de Ejecución de Hoy",
"subtitle": "Plan vs Real",
"production": "Producción",
"deliveries": "Entregas",
"approvals": "Aprobaciones",
"status": {
"no_plan": "Sin Plan",
"no_deliveries": "Sin Entregas",
"completed": "Completado",
"on_track": "En Marcha",
"at_risk": "En Riesgo"
},
"no_production_plan": "No hay producción planificada para hoy",
"no_deliveries_today": "No hay entregas programadas para hoy",
"batches_complete": "lotes completos",
"completed": "Completado",
"in_progress": "En Progreso",
"pending": "Pendiente",
"received": "Recibido",
"overdue": "Atrasado",
"pending_approvals": "Aprobaciones Pendientes",
"whats_next": "Qué Sigue",
"starts_at": "comienza a las"
},
"intelligent_system": {
"title": "Resumen del Sistema Inteligente",
"subtitle": "Automatización impulsada por IA trabajando para ti",
"ai_handling_rate": "Tasa de Gestión de IA",
"prevented_issues": "Incidencias Evitadas",
"estimated_savings": "Ahorros Estimados",
"context": "Basado en {total} alertas durante los últimos {days} días",
"prevented_issues_details": "Detalles de Incidencias Evitadas",
"no_prevented_issues": "No se evitaron incidencias esta semana - ¡todos los sistemas funcionan correctamente!",
"celebration": "¡Buenas noticias! La IA evitó {count} incidencia(s) antes de que se convirtieran en problemas.",
"ai_insight": "Análisis de IA:",
"orchestration_title": "Última Ejecución de Orquestación"
} }
} }

View File

@@ -73,6 +73,10 @@
"green": "Todo funciona correctamente", "green": "Todo funciona correctamente",
"yellow": "Algunos elementos necesitan atención", "yellow": "Algunos elementos necesitan atención",
"red": "Problemas críticos requieren acción inmediata", "red": "Problemas críticos requieren acción inmediata",
"green_simple": "✅ Todo listo para hoy",
"yellow_simple": "⚠️ Algunas cosas necesitan atención",
"red_simple": "🔴 Problemas críticos requieren acción",
"yellow_simple_with_count": "⚠️ {count} acción{count, plural, one {} other {es}} necesaria{count, plural, one {} other {s}}",
"last_updated": "Última actualización", "last_updated": "Última actualización",
"next_check": "Próxima verificación", "next_check": "Próxima verificación",
"never": "Nunca", "never": "Nunca",

View File

@@ -286,8 +286,8 @@
"message": "{{shift_name}} langileak gehiegi %{{excess_percent}}z. Langile mailak doitzea kontuan hartu lan kostuak murrizteko." "message": "{{shift_name}} langileak gehiegi %{{excess_percent}}z. Langile mailak doitzea kontuan hartu lan kostuak murrizteko."
}, },
"po_approval_needed": { "po_approval_needed": {
"title": "Erosketa Agindua #{{po_number}} onarpenaren beharra", "title": "Erosketa Agindua #{po_number} onarpenaren beharra",
"message": "{{supplier_name}}-(r)entzako erosketa agindua {{currency}} {{total_amount}} onarpenaren zain. Entreaga {{required_delivery_date}}-(e)rako behar da." "message": "{supplier_name}-(r)entzako erosketa agindua {currency} {total_amount} onarpenaren zain. Entreaga {required_delivery_date}-(e)rako behar da."
}, },
"production_batch_start": { "production_batch_start": {
"title": "Ekoizpen Lote Prest: {{product_name}}", "title": "Ekoizpen Lote Prest: {{product_name}}",

View File

@@ -65,8 +65,11 @@
"continue": "Jarraitu", "continue": "Jarraitu",
"confirm": "Berretsi", "confirm": "Berretsi",
"expand": "Zabaldu", "expand": "Zabaldu",
"collapse": "Tolestu" "collapse": "Tolestu",
"save_draft": "Zirriborroa Gorde",
"confirm_receipt": "Harrera Berretsi"
}, },
"saved": "aurreztuta",
"item": "produktua", "item": "produktua",
"items": "produktuak", "items": "produktuak",
"unknown": "Ezezaguna", "unknown": "Ezezaguna",
@@ -317,10 +320,6 @@
"profile_menu": "Profil menua", "profile_menu": "Profil menua",
"close_navigation": "Nabigazioa itxi" "close_navigation": "Nabigazioa itxi"
}, },
"header": {
"main_navigation": "Nabigazio nagusia",
"open_menu": "Nabigazioa ireki"
},
"footer": { "footer": {
"company_description": "Okindegirentzako kudeaketa sistema adimentsua. Optimizatu zure ekoizpena, inbentarioa eta salmentak adimen artifizialarekin.", "company_description": "Okindegirentzako kudeaketa sistema adimentsua. Optimizatu zure ekoizpena, inbentarioa eta salmentak adimen artifizialarekin.",
"sections": { "sections": {
@@ -416,6 +415,9 @@
"customize": "Pertsonalizatu" "customize": "Pertsonalizatu"
}, },
"header": { "header": {
"main_navigation": "Nabigazio nagusia",
"notifications": "Jakinarazpenak",
"unread_count": "{{count}} jakinarazpen irakurri gabeak",
"login": "Hasi Saioa", "login": "Hasi Saioa",
"start_free": "Hasi Doan", "start_free": "Hasi Doan",
"register": "Erregistratu", "register": "Erregistratu",

View File

@@ -169,10 +169,15 @@
"health": { "health": {
"production_on_schedule": "Ekoizpena orduan", "production_on_schedule": "Ekoizpena orduan",
"production_delayed": "{count} ekoizpen sorta atzeratuta", "production_delayed": "{count} ekoizpen sorta atzeratuta",
"production_ai_prevented": "AIk {count} ekoizpen atzerapen saihestu {count, plural, one {du} other {ditu}}",
"all_ingredients_in_stock": "Osagai guztiak stockean", "all_ingredients_in_stock": "Osagai guztiak stockean",
"ingredients_out_of_stock": "{count} osagai stockik gabe", "ingredients_out_of_stock": "{count} osagai stockik gabe",
"inventory_ai_prevented": "AIk {count} inbentario arazo saihestu {count, plural, one {du} other {ditu}}",
"no_pending_approvals": "Ez dago onarpen pendienteik", "no_pending_approvals": "Ez dago onarpen pendienteik",
"approvals_awaiting": "{count} erosketa agindu{count, plural, one {} other {k}}} onarpenaren zai", "approvals_awaiting": "{count} erosketa agindu{count, plural, one {} other {k}}} onarpenaren zai",
"procurement_ai_created": "AIk {count} erosketa agindu sortu {count, plural, one {du} other {ditu}} automatikoki",
"deliveries_on_track": "Entrega guztiak orduan",
"deliveries_pending": "{count} entrega zain",
"all_systems_operational": "Sistema guztiak martxan", "all_systems_operational": "Sistema guztiak martxan",
"critical_issues": "{count} arazo kritiko", "critical_issues": "{count} arazo kritiko",
"headline_green": "Zure okindegia arazorik gabe dabil", "headline_green": "Zure okindegia arazorik gabe dabil",
@@ -233,10 +238,94 @@
"complete_setup": "Konfigurazioa Osatu" "complete_setup": "Konfigurazioa Osatu"
} }
}, },
"action_queue_title": "Ekintza Ilara",
"total_actions": "ekintzak",
"all_caught_up": "Dena Eguneratuta!",
"no_actions_needed": "Ez da ekintzarik behar orain",
"orchestration": { "orchestration": {
"no_runs_message": "Oraindik ez da orkestraziorik exekutatu. Sakatu 'Eguneko Plangintza Exekutatu' zure lehen plana sortzeko." "no_runs_message": "Oraindik ez da orkestraziorik exekutatu. Sakatu 'Eguneko Plangintza Exekutatu' zure lehen plana sortzeko.",
"reasoning_title": "AI Orkestrazioaren Arrazoiketa",
"no_data": "Oraindik ez dago orkestrazioaren daturik eskuragarri. AIak zure eragiketak aztertuko ditu eta gomendioak egingo ditu.",
"last_run": "Azken exekuzioa",
"ai_automated": "AI Automatizatua",
"actions_completed": "ekintza osatu",
"user_needed": "Erabiltzailea Behar",
"needs_review": "zure berrikuspena behar du",
"all_handled": "guztia AIak kudeatua",
"prevented_badge": "{{count}} arazu saihestau{{count, plural, one {} other {}}",
"prevented_description": "AIak hauek proaktiboki kudeatu zituen arazo bihurtu aurretik",
"analyzed_title": "Zer Aztertu Nuen",
"actions_taken": "Zer Egin Nuen",
"prevented_issues": "Saihestutako Arazoak",
"prevented_issues_detail": "Arazo Saihespenaren Xehetasunak",
"estimated_impact": "Estimatutako Eragina",
"impact_description": "Saihestutako stockout-etan, hondakin murrizketan eta erosketa optimizatuetan oinarrituta"
}, },
"errors": { "errors": {
"failed_to_load_stats": "Huts egin du aginte-paneleko estatistikak kargatzean. Saiatu berriro mesedez." "failed_to_load_stats": "Huts egin du aginte-paneleko estatistikak kargatzean. Saiatu berriro mesedez."
},
"ai_handling_rate": {
"title": "AI Eragina Aste Honetan",
"subtitle": "Arazoak gertatu aurretik saihestutakoak",
"handling_rate_label": "alertetatik automatikoki kudeatuak",
"prevented_count_label": "Saihestuta",
"issues_text": "arazoak",
"savings_label": "Aurrezkiak",
"estimated_text": "estimatuta",
"context": "{total} alerta guztiak oinarri hartuta azken {days} egunetan.",
"view_prevented_issues": "Ikusi Saihestutako Arazoak",
"no_prevented_issues": "Ez da arazorik saihestau aste honetan - sistema guztiak ondo dabiltza!",
"error_title": "Ezin dira AI metrikak kargatu",
"error_message": "Saiatu berriro mesedez"
},
"prevented_issues": {
"title": "Saihestutako Arazoak",
"subtitle": "AI esku-hartzeak aste honetan",
"total_savings": "Aurreztutako Guztira",
"celebration": "Albiste onak! AIk {count} arazu{plural} saihestau ditu arazo bihurtu aurretik.",
"ai_insight": "AI Analisia:",
"show_less": "Gutxiago Erakutsi",
"show_more": "{{count}} Gehiago Erakutsi",
"no_issues": "Ez da arazorik saihestau aste honetan",
"no_issues_detail": "Sistema guztiak ondo dabiltza!",
"error_title": "Ezin dira saihestutako arazoak kargatu"
},
"execution_progress": {
"title": "Gaurko Exekuzio Aurrerakuntza",
"subtitle": "Plana vs Erreala",
"production": "Ekoizpena",
"deliveries": "Entregak",
"approvals": "Onarpanak",
"status": {
"no_plan": "Planik Ez",
"no_deliveries": "Entregarik Ez",
"completed": "Osatua",
"on_track": "Bidean",
"at_risk": "Arriskuan"
},
"no_production_plan": "Ez dago ekoizpen planarik gaurkoz",
"no_deliveries_today": "Ez dago entregarik programatuta gaurkoz",
"batches_complete": "lote osatu",
"completed": "Osatua",
"in_progress": "Abian",
"pending": "Zain",
"received": "Jasoa",
"overdue": "Berandu",
"pending_approvals": "Onarpen Zain",
"whats_next": "Zer Dator",
"starts_at": "hasiko da"
},
"intelligent_system": {
"title": "Sistema Adimentuaren Laburpena",
"subtitle": "IA bidezko automatizazioa zuretzat lanean",
"ai_handling_rate": "IAren Kudeaketa-Tasa",
"prevented_issues": "Saihestutako Arazoak",
"estimated_savings": "Aurrezki Estimatuak",
"context": "{total} alertatan oinarrituta azken {days} egunetan",
"prevented_issues_details": "Saihestutako Arazoen Xehetasunak",
"no_prevented_issues": "Ez da arazorik saihestatu aste honetan - sistema guztiak ondo dabiltza!",
"celebration": "Albiste onak! IAk {count} arazo saihestatu ditu arazo bihurtu aurretik.",
"ai_insight": "IAren Analisia:",
"orchestration_title": "Azken Orkestraketa-Exekuzioa"
} }
} }

View File

@@ -73,6 +73,10 @@
"green": "Dena ondo dabil", "green": "Dena ondo dabil",
"yellow": "Elementu batzuek arreta behar dute", "yellow": "Elementu batzuek arreta behar dute",
"red": "Arazo kritikoek ekintza berehalakoa behar dute", "red": "Arazo kritikoek ekintza berehalakoa behar dute",
"green_simple": "✅ Dena prest gaur",
"yellow_simple": "⚠️ Zerbait arreta behar du",
"red_simple": "🔴 Arazo kritikoek ekintza behar dute",
"yellow_simple_with_count": "⚠️ {count} ekintza behar",
"last_updated": "Azken eguneratzea", "last_updated": "Azken eguneratzea",
"next_check": "Hurrengo egiaztapena", "next_check": "Hurrengo egiaztapena",
"never": "Inoiz ez", "never": "Inoiz ez",

View File

@@ -22,6 +22,7 @@ import featuresEs from './es/features.json';
import aboutEs from './es/about.json'; import aboutEs from './es/about.json';
import demoEs from './es/demo.json'; import demoEs from './es/demo.json';
import blogEs from './es/blog.json'; import blogEs from './es/blog.json';
import alertsEs from './es/alerts.json';
// English translations // English translations
import commonEn from './en/common.json'; import commonEn from './en/common.json';
@@ -47,6 +48,7 @@ import featuresEn from './en/features.json';
import aboutEn from './en/about.json'; import aboutEn from './en/about.json';
import demoEn from './en/demo.json'; import demoEn from './en/demo.json';
import blogEn from './en/blog.json'; import blogEn from './en/blog.json';
import alertsEn from './en/alerts.json';
// Basque translations // Basque translations
import commonEu from './eu/common.json'; import commonEu from './eu/common.json';
@@ -72,6 +74,7 @@ import featuresEu from './eu/features.json';
import aboutEu from './eu/about.json'; import aboutEu from './eu/about.json';
import demoEu from './eu/demo.json'; import demoEu from './eu/demo.json';
import blogEu from './eu/blog.json'; import blogEu from './eu/blog.json';
import alertsEu from './eu/alerts.json';
// Translation resources by language // Translation resources by language
export const resources = { export const resources = {
@@ -99,6 +102,7 @@ export const resources = {
about: aboutEs, about: aboutEs,
demo: demoEs, demo: demoEs,
blog: blogEs, blog: blogEs,
alerts: alertsEs,
}, },
en: { en: {
common: commonEn, common: commonEn,
@@ -124,6 +128,7 @@ export const resources = {
about: aboutEn, about: aboutEn,
demo: demoEn, demo: demoEn,
blog: blogEn, blog: blogEn,
alerts: alertsEn,
}, },
eu: { eu: {
common: commonEu, common: commonEu,
@@ -149,6 +154,7 @@ export const resources = {
about: aboutEu, about: aboutEu,
demo: demoEu, demo: demoEu,
blog: blogEu, blog: blogEu,
alerts: alertsEu,
}, },
}; };
@@ -185,7 +191,7 @@ export const languageConfig = {
}; };
// Namespaces available in translations // Namespaces available in translations
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog'] as const; export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog', 'alerts'] as const;
export type Namespace = typeof namespaces[number]; export type Namespace = typeof namespaces[number];
// Helper function to get language display name // Helper function to get language display name

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
// Placeholder page for communications section
// This allows the nested routing to work properly
const CommunicationsPage: React.FC = () => {
return (
<div>
<Outlet />
</div>
);
};
export default CommunicationsPage;

View File

@@ -1,611 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../components/layout';
import StatsGrid from '../../components/ui/Stats/StatsGrid';
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
import { IncompleteIngredientsAlert } from '../../components/domain/dashboard/IncompleteIngredientsAlert';
import { ConfigurationProgressWidget } from '../../components/domain/dashboard/ConfigurationProgressWidget';
import PendingPOApprovals from '../../components/domain/dashboard/PendingPOApprovals';
import TodayProduction from '../../components/domain/dashboard/TodayProduction';
// Sustainability widget removed - now using stats in StatsGrid
import { EditViewModal } from '../../components/ui';
import { useTenant } from '../../stores/tenant.store';
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
import { useDashboardStats } from '../../api/hooks/dashboard';
import { usePurchaseOrder, useApprovePurchaseOrder, useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
import { useBatchDetails, useUpdateBatchStatus } from '../../api/hooks/production';
import { useRunDailyWorkflow } from '../../api';
import { ProductionStatusEnum } from '../../api';
import {
AlertTriangle,
Clock,
Euro,
Package,
FileText,
Building2,
Calendar,
CheckCircle,
X,
ShoppingCart,
Factory,
Timer,
TrendingDown,
Leaf,
Play
} from 'lucide-react';
import { showToast } from '../../utils/toast';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { availableTenants, currentTenant } = useTenant();
const { startTour } = useDemoTour();
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
// Modal state management
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null);
const [showPOModal, setShowPOModal] = useState(false);
const [showBatchModal, setShowBatchModal] = useState(false);
const [approvalNotes, setApprovalNotes] = useState('');
// Fetch real dashboard statistics
const { data: dashboardStats, isLoading: isLoadingStats, error: statsError } = useDashboardStats(
currentTenant?.id || '',
{
enabled: !!currentTenant?.id,
}
);
// Fetch PO details when modal is open
const { data: poDetails, isLoading: isLoadingPO } = usePurchaseOrder(
currentTenant?.id || '',
selectedPOId || '',
{
enabled: !!currentTenant?.id && !!selectedPOId && showPOModal
}
);
// Fetch Production batch details when modal is open
const { data: batchDetails, isLoading: isLoadingBatch } = useBatchDetails(
currentTenant?.id || '',
selectedBatchId || '',
{
enabled: !!currentTenant?.id && !!selectedBatchId && showBatchModal
}
);
// Mutations
const approvePOMutation = useApprovePurchaseOrder();
const rejectPOMutation = useRejectPurchaseOrder();
const updateBatchStatusMutation = useUpdateBatchStatus();
const orchestratorMutation = useRunDailyWorkflow();
const handleRunOrchestrator = async () => {
try {
await orchestratorMutation.mutateAsync(currentTenant?.id || '');
showToast.success('Flujo de planificación ejecutado exitosamente');
} catch (error) {
console.error('Error running orchestrator:', error);
showToast.error('Error al ejecutar flujo de planificación');
}
};
useEffect(() => {
console.log('[Dashboard] Demo mode:', isDemoMode);
console.log('[Dashboard] Should start tour:', shouldStartTour());
console.log('[Dashboard] SessionStorage demo_tour_should_start:', sessionStorage.getItem('demo_tour_should_start'));
console.log('[Dashboard] SessionStorage demo_tour_start_step:', sessionStorage.getItem('demo_tour_start_step'));
// Check if there's a tour intent from redirection (higher priority)
const shouldStartFromRedirect = sessionStorage.getItem('demo_tour_should_start') === 'true';
const redirectStartStep = parseInt(sessionStorage.getItem('demo_tour_start_step') || '0', 10);
if (isDemoMode && (shouldStartTour() || shouldStartFromRedirect)) {
console.log('[Dashboard] Starting tour in 1.5s...');
const timer = setTimeout(() => {
console.log('[Dashboard] Executing startTour()');
if (shouldStartFromRedirect) {
// Start tour from the specific step that was intended
startTour(redirectStartStep);
// Clear the redirect intent
sessionStorage.removeItem('demo_tour_should_start');
sessionStorage.removeItem('demo_tour_start_step');
} else {
// Start tour normally (from beginning or resume)
startTour();
clearTourStartPending();
}
}, 1500);
return () => clearTimeout(timer);
}
}, [isDemoMode, startTour]);
const handleViewAllProcurement = () => {
navigate('/app/operations/procurement');
};
const handleViewAllProduction = () => {
navigate('/app/operations/production');
};
const handleOrderItem = (itemId: string) => {
console.log('Ordering item:', itemId);
navigate('/app/operations/procurement');
};
const handleStartBatch = async (batchId: string) => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
showToast.success('Lote iniciado');
} catch (error) {
console.error('Error starting batch:', error);
showToast.error('Error al iniciar lote');
}
};
const handlePauseBatch = async (batchId: string) => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
showToast.success('Lote pausado');
} catch (error) {
console.error('Error pausing batch:', error);
showToast.error('Error al pausar lote');
}
};
const handleViewDetails = (batchId: string) => {
setSelectedBatchId(batchId);
setShowBatchModal(true);
};
const handleApprovePO = async (poId: string) => {
try {
await approvePOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId,
notes: 'Aprobado desde el dashboard'
});
showToast.success('Orden aprobada');
} catch (error) {
console.error('Error approving PO:', error);
showToast.error('Error al aprobar orden');
}
};
const handleRejectPO = async (poId: string) => {
try {
await rejectPOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId,
reason: 'Rechazado desde el dashboard'
});
showToast.success('Orden rechazada');
} catch (error) {
console.error('Error rejecting PO:', error);
showToast.error('Error al rechazar orden');
}
};
const handleViewPODetails = (poId: string) => {
setSelectedPOId(poId);
setShowPOModal(true);
};
const handleViewAllPOs = () => {
navigate('/app/operations/procurement');
};
// Build stats from real API data (Sales analytics removed - Professional/Enterprise tier only)
const criticalStats = React.useMemo(() => {
if (!dashboardStats) {
// Return loading/empty state
return [];
}
// Determine trend direction
const getTrendDirection = (value: number): 'up' | 'down' | 'neutral' => {
if (value > 0) return 'up';
if (value < 0) return 'down';
return 'neutral';
};
return [
{
title: t('dashboard:stats.pending_orders', 'Pending Orders'),
value: dashboardStats.pendingOrders.toString(),
icon: Clock,
variant: dashboardStats.pendingOrders > 10 ? ('warning' as const) : ('info' as const),
trend: dashboardStats.ordersTrend !== 0 ? {
value: Math.abs(dashboardStats.ordersTrend),
direction: getTrendDirection(dashboardStats.ordersTrend),
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
} : undefined,
subtitle: dashboardStats.pendingOrders > 0
? t('dashboard:messages.require_attention', 'Require attention')
: t('dashboard:messages.all_caught_up', 'All caught up!')
},
{
title: t('dashboard:stats.stock_alerts', 'Critical Stock'),
value: dashboardStats.criticalStock.toString(),
icon: AlertTriangle,
variant: dashboardStats.criticalStock > 0 ? ('error' as const) : ('success' as const),
trend: undefined, // Stock alerts don't have historical trends
subtitle: dashboardStats.criticalStock > 0
? t('dashboard:messages.action_required', 'Action required')
: t('dashboard:messages.stock_healthy', 'Stock levels healthy')
},
{
title: t('dashboard:stats.waste_reduction', 'Waste Reduction'),
value: dashboardStats.wasteReductionPercentage
? `${Math.abs(dashboardStats.wasteReductionPercentage).toFixed(1)}%`
: '0%',
icon: TrendingDown,
variant: (dashboardStats.wasteReductionPercentage || 0) >= 15 ? ('success' as const) : ('info' as const),
trend: undefined,
subtitle: (dashboardStats.wasteReductionPercentage || 0) >= 15
? t('dashboard:messages.excellent_progress', 'Excellent progress!')
: t('dashboard:messages.keep_improving', 'Keep improving')
},
{
title: t('dashboard:stats.monthly_savings', 'Monthly Savings'),
value: dashboardStats.monthlySavingsEur
? `${dashboardStats.monthlySavingsEur.toFixed(0)}`
: '€0',
icon: Leaf,
variant: 'success' as const,
trend: undefined,
subtitle: t('dashboard:messages.from_sustainability', 'From sustainability')
}
];
}, [dashboardStats, t]);
// Helper function to build PO detail sections (reused from ProcurementPage)
const buildPODetailsSections = (po: any) => {
if (!po) return [];
const getPOStatusConfig = (status: string) => {
const normalizedStatus = status?.toUpperCase().replace(/_/g, '_');
const configs: Record<string, any> = {
PENDING_APPROVAL: { text: 'Pendiente de Aprobación', color: 'var(--color-warning)' },
APPROVED: { text: 'Aprobado', color: 'var(--color-success)' },
SENT_TO_SUPPLIER: { text: 'Enviado al Proveedor', color: 'var(--color-info)' },
CONFIRMED: { text: 'Confirmado', color: 'var(--color-success)' },
RECEIVED: { text: 'Recibido', color: 'var(--color-success)' },
COMPLETED: { text: 'Completado', color: 'var(--color-success)' },
CANCELLED: { text: 'Cancelado', color: 'var(--color-error)' },
};
return configs[normalizedStatus] || { text: status, color: 'var(--color-info)' };
};
const statusConfig = getPOStatusConfig(po.status);
return [
{
title: 'Información General',
icon: FileText,
fields: [
{ label: 'Número de Orden', value: po.po_number, type: 'text' as const },
{ label: 'Estado', value: statusConfig.text, type: 'status' as const },
{ label: 'Prioridad', value: po.priority === 'urgent' ? 'Urgente' : po.priority === 'high' ? 'Alta' : po.priority === 'low' ? 'Baja' : 'Normal', type: 'text' as const },
{ label: 'Fecha de Creación', value: new Date(po.created_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }), type: 'text' as const }
]
},
{
title: 'Información del Proveedor',
icon: Building2,
fields: [
{ label: 'Proveedor', value: po.supplier?.name || po.supplier_name || 'N/A', type: 'text' as const },
{ label: 'Email', value: po.supplier?.contact_email || 'N/A', type: 'text' as const },
{ label: 'Teléfono', value: po.supplier?.contact_phone || 'N/A', type: 'text' as const }
]
},
{
title: 'Resumen Financiero',
icon: Euro,
fields: [
{ label: 'Subtotal', value: `${(typeof po.subtotal === 'string' ? parseFloat(po.subtotal) : po.subtotal || 0).toFixed(2)}`, type: 'text' as const },
{ label: 'Impuestos', value: `${(typeof po.tax_amount === 'string' ? parseFloat(po.tax_amount) : po.tax_amount || 0).toFixed(2)}`, type: 'text' as const },
{ label: 'TOTAL', value: `${(typeof po.total_amount === 'string' ? parseFloat(po.total_amount) : po.total_amount || 0).toFixed(2)}`, type: 'text' as const, highlight: true }
]
},
{
title: 'Entrega',
icon: Calendar,
fields: [
{ label: 'Fecha Requerida', value: po.required_delivery_date ? new Date(po.required_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) : 'No especificada', type: 'text' as const },
{ label: 'Fecha Esperada', value: po.expected_delivery_date ? new Date(po.expected_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' }) : 'No especificada', type: 'text' as const }
]
}
];
};
// Helper function to build Production batch detail sections
const buildBatchDetailsSections = (batch: any) => {
if (!batch) return [];
return [
{
title: 'Información General',
icon: Package,
fields: [
{ label: 'Producto', value: batch.product_name, type: 'text' as const, highlight: true },
{ label: 'Número de Lote', value: batch.batch_number, type: 'text' as const },
{ label: 'Cantidad Planificada', value: `${batch.planned_quantity} unidades`, type: 'text' as const },
{ label: 'Cantidad Real', value: batch.actual_quantity ? `${batch.actual_quantity} unidades` : 'Pendiente', type: 'text' as const },
{ label: 'Estado', value: batch.status, type: 'text' as const },
{ label: 'Prioridad', value: batch.priority, type: 'text' as const }
]
},
{
title: 'Cronograma',
icon: Clock,
fields: [
{ label: 'Inicio Planificado', value: batch.planned_start_time ? new Date(batch.planned_start_time).toLocaleString('es-ES') : 'No especificado', type: 'text' as const },
{ label: 'Fin Planificado', value: batch.planned_end_time ? new Date(batch.planned_end_time).toLocaleString('es-ES') : 'No especificado', type: 'text' as const },
{ label: 'Inicio Real', value: batch.actual_start_time ? new Date(batch.actual_start_time).toLocaleString('es-ES') : 'Pendiente', type: 'text' as const },
{ label: 'Fin Real', value: batch.actual_end_time ? new Date(batch.actual_end_time).toLocaleString('es-ES') : 'Pendiente', type: 'text' as const }
]
},
{
title: 'Producción',
icon: Factory,
fields: [
{ label: 'Personal Asignado', value: batch.staff_assigned?.join(', ') || 'No asignado', type: 'text' as const },
{ label: 'Estación', value: batch.station_id || 'No asignada', type: 'text' as const },
{ label: 'Duración Planificada', value: batch.planned_duration_minutes ? `${batch.planned_duration_minutes} minutos` : 'No especificada', type: 'text' as const }
]
},
{
title: 'Calidad y Costos',
icon: CheckCircle,
fields: [
{ label: 'Puntuación de Calidad', value: batch.quality_score ? `${batch.quality_score}/10` : 'Pendiente', type: 'text' as const },
{ label: 'Rendimiento', value: batch.yield_percentage ? `${batch.yield_percentage}%` : 'Calculando...', type: 'text' as const },
{ label: 'Costo Estimado', value: batch.estimated_cost ? `${batch.estimated_cost}` : '€0.00', type: 'text' as const },
{ label: 'Costo Real', value: batch.actual_cost ? `${batch.actual_cost}` : '€0.00', type: 'text' as const }
]
}
];
};
return (
<div className="space-y-6 p-4 sm:p-6">
<PageHeader
title={t('dashboard:title', 'Dashboard')}
description={t('dashboard:subtitle', 'Overview of your bakery operations')}
actions={[
{
id: 'run-orchestrator',
label: orchestratorMutation.isPending ? 'Ejecutando...' : 'Ejecutar Planificación Diaria',
icon: Play,
onClick: handleRunOrchestrator,
variant: 'primary', // Primary button for visibility
size: 'sm',
disabled: orchestratorMutation.isPending,
loading: orchestratorMutation.isPending
}
]}
/>
{/* Critical Metrics using StatsGrid */}
<div data-tour="dashboard-stats">
{isLoadingStats ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-32 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg animate-pulse"
/>
))}
</div>
) : statsError ? (
<div className="mb-6 p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
<p className="text-[var(--color-error)] text-sm">
{t('dashboard:errors.failed_to_load_stats', 'Failed to load dashboard statistics. Please try again.')}
</p>
</div>
) : (
<StatsGrid
stats={criticalStats}
columns={4}
gap="lg"
className="mb-6"
/>
)}
</div>
{/* Dashboard Content - Main Sections */}
<div className="space-y-6">
{/* 0. Configuration Progress Widget */}
<ConfigurationProgressWidget />
{/* 1. Real-time Alerts */}
<div data-tour="real-time-alerts">
<RealTimeAlerts />
</div>
{/* 1.5. Incomplete Ingredients Alert */}
<IncompleteIngredientsAlert />
{/* 2. Pending PO Approvals - What purchase orders need approval? */}
<div data-tour="pending-po-approvals">
<PendingPOApprovals
onApprovePO={handleApprovePO}
onRejectPO={handleRejectPO}
onViewDetails={handleViewPODetails}
onViewAllPOs={handleViewAllPOs}
maxPOs={5}
/>
</div>
{/* 3. Today's Production - What needs to be produced today? */}
<div data-tour="today-production">
<TodayProduction
onStartBatch={handleStartBatch}
onPauseBatch={handlePauseBatch}
onViewDetails={handleViewDetails}
onViewAllPlans={handleViewAllProduction}
maxBatches={5}
/>
</div>
</div>
{/* Purchase Order Details Modal */}
{showPOModal && poDetails && (
<EditViewModal
isOpen={showPOModal}
onClose={() => {
setShowPOModal(false);
setSelectedPOId(null);
}}
title={`Orden de Compra: ${poDetails.po_number}`}
subtitle={`Proveedor: ${poDetails.supplier?.name || poDetails.supplier_name || 'N/A'}`}
mode="view"
sections={buildPODetailsSections(poDetails)}
loading={isLoadingPO}
statusIndicator={{
color: poDetails.status === 'PENDING_APPROVAL' ? 'var(--color-warning)' :
poDetails.status === 'APPROVED' ? 'var(--color-success)' :
'var(--color-info)',
text: poDetails.status === 'PENDING_APPROVAL' ? 'Pendiente de Aprobación' :
poDetails.status === 'APPROVED' ? 'Aprobado' :
poDetails.status || 'N/A',
icon: ShoppingCart
}}
actions={
poDetails.status === 'PENDING_APPROVAL' ? [
{
label: 'Aprobar',
onClick: async () => {
try {
await approvePOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId: poDetails.id,
notes: 'Aprobado desde el dashboard'
});
showToast.success('Orden aprobada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error approving PO:', error);
showToast.error('Error al aprobar orden');
}
},
variant: 'primary' as const,
icon: CheckCircle
},
{
label: 'Rechazar',
onClick: async () => {
try {
await rejectPOMutation.mutateAsync({
tenantId: currentTenant?.id || '',
poId: poDetails.id,
reason: 'Rechazado desde el dashboard'
});
showToast.success('Orden rechazada');
setShowPOModal(false);
setSelectedPOId(null);
} catch (error) {
console.error('Error rejecting PO:', error);
showToast.error('Error al rechazar orden');
}
},
variant: 'outline' as const,
icon: X
}
] : undefined
}
/>
)}
{/* Production Batch Details Modal */}
{showBatchModal && batchDetails && (
<EditViewModal
isOpen={showBatchModal}
onClose={() => {
setShowBatchModal(false);
setSelectedBatchId(null);
}}
title={batchDetails.product_name}
subtitle={`Lote #${batchDetails.batch_number}`}
mode="view"
sections={buildBatchDetailsSections(batchDetails)}
loading={isLoadingBatch}
statusIndicator={{
color: batchDetails.status === 'PENDING' ? 'var(--color-warning)' :
batchDetails.status === 'IN_PROGRESS' ? 'var(--color-info)' :
batchDetails.status === 'COMPLETED' ? 'var(--color-success)' :
batchDetails.status === 'FAILED' ? 'var(--color-error)' :
'var(--color-info)',
text: batchDetails.status === 'PENDING' ? 'Pendiente' :
batchDetails.status === 'IN_PROGRESS' ? 'En Progreso' :
batchDetails.status === 'COMPLETED' ? 'Completado' :
batchDetails.status === 'FAILED' ? 'Fallido' :
batchDetails.status === 'ON_HOLD' ? 'Pausado' :
batchDetails.status || 'N/A',
icon: Factory
}}
actions={
batchDetails.status === 'PENDING' ? [
{
label: 'Iniciar Lote',
onClick: async () => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.IN_PROGRESS }
});
showToast.success('Lote iniciado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error starting batch:', error);
showToast.error('Error al iniciar lote');
}
},
variant: 'primary' as const,
icon: CheckCircle
}
] : batchDetails.status === 'IN_PROGRESS' ? [
{
label: 'Pausar Lote',
onClick: async () => {
try {
await updateBatchStatusMutation.mutateAsync({
tenantId: currentTenant?.id || '',
batchId: batchDetails.id,
statusUpdate: { status: ProductionStatusEnum.ON_HOLD }
});
showToast.success('Lote pausado');
setShowBatchModal(false);
setSelectedBatchId(null);
} catch (error) {
console.error('Error pausing batch:', error);
showToast.error('Error al pausar lote');
}
},
variant: 'outline' as const,
icon: X
}
] : undefined
}
/>
)}
</div>
);
};
export default DashboardPage;

View File

@@ -123,6 +123,15 @@ export function NewDashboardPage() {
const { notifications: deliveryNotifications } = useDeliveryNotifications(); const { notifications: deliveryNotifications } = useDeliveryNotifications();
const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications(); const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications();
console.log('🔄 [Dashboard] Component render - notification counts:', {
batch: batchNotifications.length,
delivery: deliveryNotifications.length,
orchestration: orchestrationNotifications.length,
batchIds: batchNotifications.map(n => n.id).join(','),
deliveryIds: deliveryNotifications.map(n => n.id).join(','),
orchestrationIds: orchestrationNotifications.map(n => n.id).join(','),
});
// SSE connection status // SSE connection status
const sseConnected = true; // Simplified - based on other notification hooks const sseConnected = true; // Simplified - based on other notification hooks
@@ -152,56 +161,118 @@ export function NewDashboardPage() {
}); });
// Track the latest notification ID to prevent re-running on same notification // Track the latest notification ID to prevent re-running on same notification
const latestBatchNotificationId = useMemo(() => // Use stringified ID array to create stable dependency that only changes when IDs actually change
batchNotifications.length > 0 ? batchNotifications[0]?.id : null, const batchIdsString = JSON.stringify(batchNotifications.map(n => n.id));
[batchNotifications] const deliveryIdsString = JSON.stringify(deliveryNotifications.map(n => n.id));
); const orchestrationIdsString = JSON.stringify(orchestrationNotifications.map(n => n.id));
const latestDeliveryNotificationId = useMemo(() => console.log('📝 [Dashboard] Stringified ID arrays:', {
deliveryNotifications.length > 0 ? deliveryNotifications[0]?.id : null, batchIdsString,
[deliveryNotifications] deliveryIdsString,
); orchestrationIdsString,
});
const latestOrchestrationNotificationId = useMemo(() => const latestBatchNotificationId = useMemo(() => {
orchestrationNotifications.length > 0 ? orchestrationNotifications[0]?.id : null, const result = batchNotifications.length === 0 ? '' : (batchNotifications[0]?.id || '');
[orchestrationNotifications] console.log('🧮 [Dashboard] latestBatchNotificationId useMemo recalculated:', {
); result,
dependency: batchIdsString,
notificationCount: batchNotifications.length,
});
return result;
}, [batchIdsString]);
const latestDeliveryNotificationId = useMemo(() => {
const result = deliveryNotifications.length === 0 ? '' : (deliveryNotifications[0]?.id || '');
console.log('🧮 [Dashboard] latestDeliveryNotificationId useMemo recalculated:', {
result,
dependency: deliveryIdsString,
notificationCount: deliveryNotifications.length,
});
return result;
}, [deliveryIdsString]);
const latestOrchestrationNotificationId = useMemo(() => {
const result = orchestrationNotifications.length === 0 ? '' : (orchestrationNotifications[0]?.id || '');
console.log('🧮 [Dashboard] latestOrchestrationNotificationId useMemo recalculated:', {
result,
dependency: orchestrationIdsString,
notificationCount: orchestrationNotifications.length,
});
return result;
}, [orchestrationIdsString]);
useEffect(() => { useEffect(() => {
const currentBatchNotificationId = latestBatchNotificationId || ''; console.log('⚡ [Dashboard] batchNotifications useEffect triggered', {
if (currentBatchNotificationId && latestBatchNotificationId,
currentBatchNotificationId !== prevBatchNotificationsRef.current) { prevValue: prevBatchNotificationsRef.current,
prevBatchNotificationsRef.current = currentBatchNotificationId; hasChanged: latestBatchNotificationId !== prevBatchNotificationsRef.current,
notificationCount: batchNotifications.length,
firstNotification: batchNotifications[0],
});
if (latestBatchNotificationId &&
latestBatchNotificationId !== prevBatchNotificationsRef.current) {
console.log('🔥 [Dashboard] NEW batch notification detected, updating ref and refetching');
prevBatchNotificationsRef.current = latestBatchNotificationId;
const latest = batchNotifications[0]; const latest = batchNotifications[0];
if (['batch_completed', 'batch_started'].includes(latest.event_type)) { if (['batch_completed', 'batch_started'].includes(latest.event_type)) {
console.log('🚀 [Dashboard] Triggering refetch for batch event:', latest.event_type);
refetchCallbacksRef.current.refetchExecutionProgress(); refetchCallbacksRef.current.refetchExecutionProgress();
refetchCallbacksRef.current.refetchHealth(); refetchCallbacksRef.current.refetchHealth();
} else {
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
} }
} }
}, [latestBatchNotificationId]); // Only run when a NEW notification arrives }, [latestBatchNotificationId]); // Only run when a NEW notification arrives
useEffect(() => { useEffect(() => {
const currentDeliveryNotificationId = latestDeliveryNotificationId || ''; console.log('⚡ [Dashboard] deliveryNotifications useEffect triggered', {
if (currentDeliveryNotificationId && latestDeliveryNotificationId,
currentDeliveryNotificationId !== prevDeliveryNotificationsRef.current) { prevValue: prevDeliveryNotificationsRef.current,
prevDeliveryNotificationsRef.current = currentDeliveryNotificationId; hasChanged: latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current,
notificationCount: deliveryNotifications.length,
firstNotification: deliveryNotifications[0],
});
if (latestDeliveryNotificationId &&
latestDeliveryNotificationId !== prevDeliveryNotificationsRef.current) {
console.log('🔥 [Dashboard] NEW delivery notification detected, updating ref and refetching');
prevDeliveryNotificationsRef.current = latestDeliveryNotificationId;
const latest = deliveryNotifications[0]; const latest = deliveryNotifications[0];
if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) { if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) {
console.log('🚀 [Dashboard] Triggering refetch for delivery event:', latest.event_type);
refetchCallbacksRef.current.refetchExecutionProgress(); refetchCallbacksRef.current.refetchExecutionProgress();
refetchCallbacksRef.current.refetchHealth(); refetchCallbacksRef.current.refetchHealth();
} else {
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
} }
} }
}, [latestDeliveryNotificationId]); // Only run when a NEW notification arrives }, [latestDeliveryNotificationId]); // Only run when a NEW notification arrives
useEffect(() => { useEffect(() => {
const currentOrchestrationNotificationId = latestOrchestrationNotificationId || ''; console.log('⚡ [Dashboard] orchestrationNotifications useEffect triggered', {
if (currentOrchestrationNotificationId && latestOrchestrationNotificationId,
currentOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) { prevValue: prevOrchestrationNotificationsRef.current,
prevOrchestrationNotificationsRef.current = currentOrchestrationNotificationId; hasChanged: latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current,
notificationCount: orchestrationNotifications.length,
firstNotification: orchestrationNotifications[0],
});
if (latestOrchestrationNotificationId &&
latestOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) {
console.log('🔥 [Dashboard] NEW orchestration notification detected, updating ref and refetching');
prevOrchestrationNotificationsRef.current = latestOrchestrationNotificationId;
const latest = orchestrationNotifications[0]; const latest = orchestrationNotifications[0];
if (latest.event_type === 'orchestration_run_completed') { if (latest.event_type === 'orchestration_run_completed') {
console.log('🚀 [Dashboard] Triggering refetch for orchestration event:', latest.event_type);
refetchCallbacksRef.current.refetchOrchestration(); refetchCallbacksRef.current.refetchOrchestration();
refetchCallbacksRef.current.refetchActionQueue(); refetchCallbacksRef.current.refetchActionQueue();
} else {
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
} }
} }
}, [latestOrchestrationNotificationId]); // Only run when a NEW notification arrives }, [latestOrchestrationNotificationId]); // Only run when a NEW notification arrives
@@ -432,27 +503,21 @@ export function NewDashboardPage() {
// Note: startTour removed from deps to prevent infinite loop - the effect guards with sessionStorage ensure it only runs once // Note: startTour removed from deps to prevent infinite loop - the effect guards with sessionStorage ensure it only runs once
return ( return (
<div className="min-h-screen pb-20 md:pb-8"> <div className="min-h-screen pb-20 md:pb-8 bg-[var(--bg-primary)]">
{/* Mobile-optimized container */} {/* Mobile-optimized container */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-3xl md:text-4xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('dashboard:title')}</h1> <h1 className="text-3xl md:text-4xl font-bold text-[var(--text-primary)]">{t('dashboard:title')}</h1>
<p className="mt-1" style={{ color: 'var(--text-secondary)' }}>{t('dashboard:subtitle')}</p> <p className="mt-1 text-[var(--text-secondary)]">{t('dashboard:subtitle')}</p>
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={handleRefreshAll} onClick={handleRefreshAll}
className="flex items-center gap-2 px-4 py-2 rounded-lg font-semibold transition-colors duration-200" className="flex items-center gap-2 px-4 py-2 rounded-lg font-semibold transition-colors duration-200 border border-[var(--border-primary)] bg-[var(--bg-primary)] text-[var(--text-secondary)]"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
border: '1px solid',
color: 'var(--text-secondary)'
}}
> >
<RefreshCw className="w-5 h-5" /> <RefreshCw className="w-5 h-5" />
<span className="hidden sm:inline">{t('common:actions.refresh')}</span> <span className="hidden sm:inline">{t('common:actions.refresh')}</span>
@@ -461,23 +526,19 @@ export function NewDashboardPage() {
{/* Unified Add Button with Keyboard Shortcut */} {/* Unified Add Button with Keyboard Shortcut */}
<button <button
onClick={() => setIsAddWizardOpen(true)} onClick={() => setIsAddWizardOpen(true)}
className="group relative flex items-center gap-2 px-6 py-2.5 rounded-lg font-semibold transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0" className="group relative flex items-center gap-2 px-6 py-2.5 rounded-lg font-semibold transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white"
style={{
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
color: 'white'
}}
title={`Quick Add (${navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+K)`} title={`Quick Add (${navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+K)`}
> >
<Plus className="w-5 h-5" /> <Plus className="w-5 h-5" />
<span className="hidden sm:inline">{t('common:actions.add')}</span> <span className="hidden sm:inline">{t('common:actions.add')}</span>
<Sparkles className="w-4 h-4 opacity-80" /> <Sparkles className="w-4 h-4 opacity-80" />
{/* Keyboard shortcut badge - shown on hover */} {/* Keyboard shortcut badge - shown on hover */}
<span className="hidden lg:flex absolute -bottom-8 left-1/2 -translate-x-1/2 items-center gap-1 px-2 py-1 rounded text-xs font-mono opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap pointer-events-none" style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-secondary)', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}> <span className="hidden lg:flex absolute -bottom-8 left-1/2 -translate-x-1/2 items-center gap-1 px-2 py-1 rounded text-xs font-mono opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap pointer-events-none bg-[var(--bg-primary)] text-[var(--text-secondary)] shadow-sm">
<kbd className="px-1.5 py-0.5 rounded text-xs font-semibold" style={{ backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-secondary)' }}> <kbd className="px-1.5 py-0.5 rounded text-xs font-semibold bg-[var(--bg-tertiary)] border border-[var(--border-secondary)]">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'} {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}
</kbd> </kbd>
<span>+</span> <span>+</span>
<kbd className="px-1.5 py-0.5 rounded text-xs font-semibold" style={{ backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-secondary)' }}> <kbd className="px-1.5 py-0.5 rounded text-xs font-semibold bg-[var(--bg-tertiary)] border border-[var(--border-secondary)]">
K K
</kbd> </kbd>
</span> </span>
@@ -545,43 +606,39 @@ export function NewDashboardPage() {
</div> </div>
{/* SECTION 6: Quick Action Links */} {/* SECTION 6: Quick Action Links */}
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>{t('dashboard:sections.quick_actions')}</h2> <h2 className="text-xl font-bold mb-4 text-[var(--text-primary)]">{t('dashboard:sections.quick_actions')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<button <button
onClick={() => navigate('/app/operations/procurement')} onClick={() => navigate('/app/operations/procurement')}
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group" className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-info)]"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-info)' }}
> >
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_orders')}</span> <span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_orders')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-info)' }} /> <ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-info)]" />
</button> </button>
<button <button
onClick={() => navigate('/app/operations/production')} onClick={() => navigate('/app/operations/production')}
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group" className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-success)]"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-success)' }}
> >
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_production')}</span> <span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_production')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-success)' }} /> <ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-success)]" />
</button> </button>
<button <button
onClick={() => navigate('/app/database/inventory')} onClick={() => navigate('/app/database/inventory')}
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group" className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-secondary)]"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-secondary)' }}
> >
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_inventory')}</span> <span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_inventory')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-secondary)' }} /> <ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-secondary)]" />
</button> </button>
<button <button
onClick={() => navigate('/app/database/suppliers')} onClick={() => navigate('/app/database/suppliers')}
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group" className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group bg-[var(--bg-tertiary)] border-l-4 border-l-[var(--color-warning)]"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-warning)' }}
> >
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_suppliers')}</span> <span className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.view_suppliers')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-warning)' }} /> <ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200 text-[var(--color-warning)]" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -64,11 +64,45 @@ export const DemoPage: React.FC = () => {
} }
// Check if ready to redirect // Check if ready to redirect
const hasUsableData = statusData.total_records_cloned > 100; // CRITICAL: Wait for inventory, recipes, AND suppliers to complete
// to prevent dashboard from showing SetupWizardBlocker
const progress = statusData.progress || {};
const inventoryReady = progress.inventory?.status === 'completed';
const recipesReady = progress.recipes?.status === 'completed';
const suppliersReady = progress.suppliers?.status === 'completed';
const criticalServicesReady = inventoryReady && recipesReady && suppliersReady;
// Additionally verify that we have minimum required data to bypass SetupWizardBlocker
// The SetupWizardBlocker requires: 3+ ingredients, 1+ suppliers, 1+ recipes
// Ensure progress data exists for all required services
const hasInventoryProgress = !!progress.inventory;
const hasSuppliersProgress = !!progress.suppliers;
const hasRecipesProgress = !!progress.recipes;
// Extract counts with defensive checks
const ingredientsCount = hasInventoryProgress ? (progress.inventory.details?.ingredients || 0) : 0;
const suppliersCount = hasSuppliersProgress ? (progress.suppliers.details?.suppliers || 0) : 0;
const recipesCount = hasRecipesProgress ? (progress.recipes.details?.recipes || 0) : 0;
// Verify we have the minimum required counts
const hasMinimumIngredients = (typeof ingredientsCount === 'number' && ingredientsCount >= 3);
const hasMinimumSuppliers = (typeof suppliersCount === 'number' && suppliersCount >= 1);
const hasMinimumRecipes = (typeof recipesCount === 'number' && recipesCount >= 1);
// Ensure all required services have completed AND we have minimum data
const hasMinimumRequiredData =
hasInventoryProgress &&
hasSuppliersProgress &&
hasRecipesProgress &&
hasMinimumIngredients &&
hasMinimumSuppliers &&
hasMinimumRecipes;
const shouldRedirect = const shouldRedirect =
statusData.status === 'ready' || (statusData.status === 'ready' && hasMinimumRequiredData) || // Ready status AND minimum required data
(statusData.status === 'partial' && hasUsableData) || (criticalServicesReady && hasMinimumRequiredData); // Critical services done + minimum required data
(statusData.status === 'failed' && hasUsableData);
if (shouldRedirect) { if (shouldRedirect) {
// Show 100% before redirect // Show 100% before redirect

View File

@@ -514,6 +514,7 @@ export const routesConfig: RouteConfig[] = [
], ],
}, },
// Settings Section // Settings Section
{ {
path: '/app/settings', path: '/app/settings',

View File

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

View File

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

View File

@@ -3,25 +3,39 @@
* Provides grouping, filtering, sorting, and categorization logic for alerts * Provides grouping, filtering, sorting, and categorization logic for alerts
*/ */
import { NotificationData } from '../hooks/useNotifications'; 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 AlertSeverity = 'urgent' | 'high' | 'medium' | 'low';
export type AlertCategory = 'inventory' | 'production' | 'orders' | 'equipment' | 'quality' | 'suppliers' | 'other'; export type AlertCategory = 'inventory' | 'production' | 'orders' | 'equipment' | 'quality' | 'suppliers' | 'other';
export type TimeGroup = 'today' | 'yesterday' | 'this_week' | 'older'; 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 { export interface AlertGroup {
id: string; id: string;
type: 'time' | 'category' | 'similarity'; type: 'time' | 'category' | 'similarity';
key: string; key: string;
title: string; title: string;
count: number; count: number;
severity: AlertSeverity; priority_level: PriorityLevel;
alerts: NotificationData[]; alerts: Alert[];
collapsed?: boolean; collapsed?: boolean;
} }
export interface AlertFilters { export interface AlertFilters {
severities: AlertSeverity[]; priorities: PriorityLevel[];
categories: AlertCategory[]; categories: AlertCategory[];
timeRange: TimeGroup | 'all'; timeRange: TimeGroup | 'all';
search: string; search: string;
@@ -37,8 +51,17 @@ export interface SnoozedAlert {
/** /**
* Categorize alert based on title and message content * Categorize alert based on title and message content
*/ */
export function categorizeAlert(alert: NotificationData): AlertCategory { export function categorizeAlert(alert: AlertOrNotification, t?: TFunction): AlertCategory {
const text = `${alert.title} ${alert.message}`.toLowerCase(); 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')) { if (text.includes('stock') || text.includes('inventario') || text.includes('caducad') || text.includes('expi')) {
return 'inventory'; return 'inventory';
@@ -138,12 +161,12 @@ export function getTimeGroupName(group: TimeGroup, locale: string = 'es'): strin
/** /**
* Check if two alerts are similar enough to group together * Check if two alerts are similar enough to group together
*/ */
export function areAlertsSimilar(alert1: NotificationData, alert2: NotificationData): boolean { export function areAlertsSimilar(alert1: Alert, alert2: Alert): boolean {
// Must be same category and severity // Must be same category and severity
if (categorizeAlert(alert1) !== categorizeAlert(alert2)) { if (categorizeAlert(alert1) !== categorizeAlert(alert2)) {
return false; return false;
} }
if (alert1.severity !== alert2.severity) { if (getSeverity(alert1) !== getSeverity(alert2)) {
return false; return false;
} }
@@ -173,11 +196,11 @@ export function areAlertsSimilar(alert1: NotificationData, alert2: NotificationD
/** /**
* Group alerts by time periods * Group alerts by time periods
*/ */
export function groupAlertsByTime(alerts: NotificationData[]): AlertGroup[] { export function groupAlertsByTime(alerts: Alert[]): AlertGroup[] {
const groups: Map<TimeGroup, NotificationData[]> = new Map(); const groups: Map<TimeGroup, Alert[]> = new Map();
alerts.forEach(alert => { alerts.forEach(alert => {
const timeGroup = getTimeGroup(alert.timestamp); const timeGroup = getTimeGroup(alert.created_at);
if (!groups.has(timeGroup)) { if (!groups.has(timeGroup)) {
groups.set(timeGroup, []); groups.set(timeGroup, []);
} }
@@ -207,8 +230,8 @@ export function groupAlertsByTime(alerts: NotificationData[]): AlertGroup[] {
/** /**
* Group alerts by category * Group alerts by category
*/ */
export function groupAlertsByCategory(alerts: NotificationData[]): AlertGroup[] { export function groupAlertsByCategory(alerts: Alert[]): AlertGroup[] {
const groups: Map<AlertCategory, NotificationData[]> = new Map(); const groups: Map<AlertCategory, Alert[]> = new Map();
alerts.forEach(alert => { alerts.forEach(alert => {
const category = categorizeAlert(alert); const category = categorizeAlert(alert);
@@ -240,7 +263,7 @@ export function groupAlertsByCategory(alerts: NotificationData[]): AlertGroup[]
/** /**
* Group similar alerts together * Group similar alerts together
*/ */
export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] { export function groupSimilarAlerts(alerts: Alert[]): AlertGroup[] {
const groups: AlertGroup[] = []; const groups: AlertGroup[] = [];
const processed = new Set<string>(); const processed = new Set<string>();
@@ -264,7 +287,7 @@ export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
groups.push({ groups.push({
id: `similar-${alert.id}`, id: `similar-${alert.id}`,
type: 'similarity', type: 'similarity',
key: `${category}-${alert.severity}`, key: `${category}-${getSeverity(alert)}`,
title: `${similarAlerts.length} alertas de ${getCategoryName(category).toLowerCase()}`, title: `${similarAlerts.length} alertas de ${getCategoryName(category).toLowerCase()}`,
count: similarAlerts.length, count: similarAlerts.length,
severity: highestSeverity, severity: highestSeverity,
@@ -279,7 +302,7 @@ export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
key: alert.id, key: alert.id,
title: alert.title, title: alert.title,
count: 1, count: 1,
severity: alert.severity as AlertSeverity, severity: getSeverity(alert) as AlertSeverity,
alerts: [alert], alerts: [alert],
}); });
} }
@@ -291,11 +314,11 @@ export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
/** /**
* Get highest severity from a list of alerts * Get highest severity from a list of alerts
*/ */
export function getHighestSeverity(alerts: NotificationData[]): AlertSeverity { export function getHighestSeverity(alerts: Alert[]): AlertSeverity {
const severityOrder: AlertSeverity[] = ['urgent', 'high', 'medium', 'low']; const severityOrder: AlertSeverity[] = ['urgent', 'high', 'medium', 'low'];
for (const severity of severityOrder) { for (const severity of severityOrder) {
if (alerts.some(alert => alert.severity === severity)) { if (alerts.some(alert => getSeverity(alert) === severity)) {
return severity; return severity;
} }
} }
@@ -306,7 +329,7 @@ export function getHighestSeverity(alerts: NotificationData[]): AlertSeverity {
/** /**
* Sort alerts by severity and timestamp * Sort alerts by severity and timestamp
*/ */
export function sortAlerts(alerts: NotificationData[]): NotificationData[] { export function sortAlerts(alerts: Alert[]): Alert[] {
const severityOrder: Record<AlertSeverity, number> = { const severityOrder: Record<AlertSeverity, number> = {
urgent: 4, urgent: 4,
high: 3, high: 3,
@@ -316,13 +339,13 @@ export function sortAlerts(alerts: NotificationData[]): NotificationData[] {
return [...alerts].sort((a, b) => { return [...alerts].sort((a, b) => {
// First by severity // First by severity
const severityDiff = severityOrder[b.severity as AlertSeverity] - severityOrder[a.severity as AlertSeverity]; const severityDiff = severityOrder[getSeverity(b) as AlertSeverity] - severityOrder[getSeverity(a) as AlertSeverity];
if (severityDiff !== 0) { if (severityDiff !== 0) {
return severityDiff; return severityDiff;
} }
// Then by timestamp (newest first) // Then by timestamp (newest first)
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
}); });
} }
@@ -330,13 +353,14 @@ export function sortAlerts(alerts: NotificationData[]): NotificationData[] {
* Filter alerts based on criteria * Filter alerts based on criteria
*/ */
export function filterAlerts( export function filterAlerts(
alerts: NotificationData[], alerts: Alert[],
filters: AlertFilters, filters: AlertFilters,
snoozedAlerts: Map<string, SnoozedAlert> snoozedAlerts: Map<string, SnoozedAlert>,
): NotificationData[] { t?: TFunction
): Alert[] {
return alerts.filter(alert => { return alerts.filter(alert => {
// Filter by severity // Filter by priority
if (filters.severities.length > 0 && !filters.severities.includes(alert.severity as AlertSeverity)) { if (filters.priorities.length > 0 && !filters.priorities.includes(alert.priority_level as PriorityLevel)) {
return false; return false;
} }
@@ -350,7 +374,7 @@ export function filterAlerts(
// Filter by time range // Filter by time range
if (filters.timeRange !== 'all') { if (filters.timeRange !== 'all') {
const timeGroup = getTimeGroup(alert.timestamp); const timeGroup = getTimeGroup(alert.created_at);
if (timeGroup !== filters.timeRange) { if (timeGroup !== filters.timeRange) {
return false; return false;
} }
@@ -359,9 +383,21 @@ export function filterAlerts(
// Filter by search text // Filter by search text
if (filters.search.trim()) { if (filters.search.trim()) {
const searchLower = filters.search.toLowerCase(); const searchLower = filters.search.toLowerCase();
const searchableText = `${alert.title} ${alert.message}`.toLowerCase();
if (!searchableText.includes(searchLower)) { // If translation function is provided, search in translated text
return false; 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;
}
} }
} }
@@ -453,9 +489,10 @@ export interface ContextualAction {
variant: 'primary' | 'secondary' | 'outline'; variant: 'primary' | 'secondary' | 'outline';
action: string; // action identifier action: string; // action identifier
route?: string; // navigation route route?: string; // navigation route
metadata?: Record<string, any>; // Additional action metadata
} }
export function getContextualActions(alert: NotificationData): ContextualAction[] { export function getContextualActions(alert: Alert): ContextualAction[] {
const category = categorizeAlert(alert); const category = categorizeAlert(alert);
const text = `${alert.title} ${alert.message}`.toLowerCase(); const text = `${alert.title} ${alert.message}`.toLowerCase();
@@ -529,14 +566,14 @@ export function getContextualActions(alert: NotificationData): ContextualAction[
* Search alerts with highlighting * Search alerts with highlighting
*/ */
export interface SearchMatch { export interface SearchMatch {
alert: NotificationData; alert: Alert;
highlights: { highlights: {
title: boolean; title: boolean;
message: boolean; message: boolean;
}; };
} }
export function searchAlerts(alerts: NotificationData[], query: string): SearchMatch[] { export function searchAlerts(alerts: Alert[], query: string): SearchMatch[] {
if (!query.trim()) { if (!query.trim()) {
return alerts.map(alert => ({ return alerts.map(alert => ({
alert, alert,
@@ -573,7 +610,7 @@ export interface AlertStats {
} }
export function getAlertStatistics( export function getAlertStatistics(
alerts: NotificationData[], alerts: Alert[],
snoozedAlerts: Map<string, SnoozedAlert> snoozedAlerts: Map<string, SnoozedAlert>
): AlertStats { ): AlertStats {
const stats: AlertStats = { const stats: AlertStats = {
@@ -585,10 +622,10 @@ export function getAlertStatistics(
}; };
alerts.forEach(alert => { alerts.forEach(alert => {
stats.bySeverity[alert.severity as AlertSeverity]++; stats.bySeverity[getSeverity(alert) as AlertSeverity]++;
stats.byCategory[categorizeAlert(alert)]++; stats.byCategory[categorizeAlert(alert)]++;
if (!alert.read) { if (alert.status === 'active') {
stats.unread++; stats.unread++;
} }

View File

@@ -0,0 +1,152 @@
/**
* Alert i18n Translation Utility
*
* Handles translation of alert titles and messages using i18n keys from backend enrichment.
* Falls back to raw title/message if i18n data is not available.
*/
import { TFunction } from 'i18next';
export interface AlertI18nData {
title_key?: string;
title_params?: Record<string, any>;
message_key?: string;
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
*
* @param alert - Alert object
* @param t - i18next translation function
* @returns Translated or fallback title
*/
export function translateAlertTitle(
alert: {
title: string;
metadata?: Record<string, any>;
},
t: TFunction
): string {
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
if (!i18nData?.title_key) {
return alert.title;
}
try {
const translated = t(i18nData.title_key, i18nData.title_params || {});
return translated !== i18nData.title_key ? translated : alert.title;
} catch (error) {
console.warn(`Failed to translate alert title with key: ${i18nData.title_key}`, error);
return alert.title;
}
}
/**
* Translates alert message only
*
* @param alert - Alert object
* @param t - i18next translation function
* @returns Translated or fallback message
*/
export function translateAlertMessage(
alert: {
message: string;
metadata?: Record<string, any>;
},
t: TFunction
): string {
const i18nData = alert.metadata?.i18n as AlertI18nData | undefined;
if (!i18nData?.message_key) {
return alert.message;
}
try {
const translated = t(i18nData.message_key, i18nData.message_params || {});
return translated !== i18nData.message_key ? translated : alert.message;
} catch (error) {
console.warn(`Failed to translate alert message with key: ${i18nData.message_key}`, error);
return alert.message;
}
}
/**
* Checks if alert has i18n data available
*
* @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));
}

View File

@@ -2,61 +2,72 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [ // Enable debug mode for development builds
react(), const isDevelopment = mode === 'development';
// PWA plugin temporarily disabled to avoid service worker conflicts
// VitePWA can be re-enabled later for production PWA features return {
], plugins: [
resolve: { react(),
alias: { // PWA plugin temporarily disabled to avoid service worker conflicts
'@': path.resolve(__dirname, './src'), // VitePWA can be re-enabled later for production PWA features
'@components': path.resolve(__dirname, './src/components'), ],
'@pages': path.resolve(__dirname, './src/pages'), resolve: {
'@hooks': path.resolve(__dirname, './src/hooks'), alias: {
'@stores': path.resolve(__dirname, './src/stores'), '@': path.resolve(__dirname, './src'),
'@services': path.resolve(__dirname, './src/services'), '@components': path.resolve(__dirname, './src/components'),
'@utils': path.resolve(__dirname, './src/utils'), '@pages': path.resolve(__dirname, './src/pages'),
'@types': path.resolve(__dirname, './src/types'), '@hooks': path.resolve(__dirname, './src/hooks'),
}, '@stores': path.resolve(__dirname, './src/stores'),
}, '@services': path.resolve(__dirname, './src/services'),
server: { '@utils': path.resolve(__dirname, './src/utils'),
host: '0.0.0.0', // Important for Docker '@types': path.resolve(__dirname, './src/types'),
port: 3000,
watch: {
// Use polling for Docker/Kubernetes compatibility
usePolling: true,
ignored: [
'**/node_modules/**',
'**/dist/**',
'**/.git/**',
],
},
proxy: {
'/api': {
target: process.env.VITE_API_URL !== undefined
? (process.env.VITE_API_URL || '') // Use value or empty string
: (process.env.NODE_ENV === 'development' && process.env.KUBERNETES_SERVICE_HOST
? 'http://gateway-service:8000' // Kubernetes internal service
: 'http://localhost:8000'), // Local development
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
}, },
}, },
}, server: {
build: { host: '0.0.0.0', // Important for Docker
outDir: 'dist', port: 3000,
sourcemap: true, watch: {
rollupOptions: { // Use polling for Docker/Kubernetes compatibility
external: ['/runtime-config.js'], // Externalize runtime config to avoid bundling usePolling: true,
output: { ignored: [
manualChunks: { '**/node_modules/**',
vendor: ['react', 'react-dom', 'react-router-dom'], '**/dist/**',
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'], '**/.git/**',
charts: ['recharts'], ],
forms: ['react-hook-form', 'zod'], },
proxy: {
'/api': {
target: process.env.VITE_API_URL !== undefined
? (process.env.VITE_API_URL || '') // Use value or empty string
: (process.env.NODE_ENV === 'development' && process.env.KUBERNETES_SERVICE_HOST
? 'http://gateway-service:8000' // Kubernetes internal service
: 'http://localhost:8000'), // Local development
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
}, },
}, },
}, },
}, build: {
outDir: 'dist',
// In development mode: inline source maps for better debugging
// In production mode: external source maps
sourcemap: isDevelopment ? 'inline' : true,
// In development mode: disable minification for readable errors
// In production mode: use esbuild minification
minify: isDevelopment ? false : 'esbuild',
rollupOptions: {
output: {
// In development mode: don't split chunks (easier debugging)
// In production mode: split chunks for better caching
manualChunks: isDevelopment ? undefined : {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
charts: ['recharts'],
forms: ['react-hook-form', 'zod'],
},
},
},
},
};
}); });

View File

@@ -6,6 +6,9 @@ COPY shared/ /shared/
# Then your main service stage # Then your main service stage
FROM python:3.11-slim FROM python:3.11-slim
# Create non-root user for security
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies
@@ -26,9 +29,15 @@ COPY --from=shared /shared /app/shared
# Copy application code # Copy application code
COPY gateway/ . COPY gateway/ .
# Change ownership to non-root user
RUN chown -R appuser:appgroup /app
# Add shared libraries to Python path # Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:${PYTHONPATH:-}" ENV PYTHONPATH="/app:/app/shared:${PYTHONPATH:-}"
# Switch to non-root user
USER appuser
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000

View File

@@ -144,18 +144,226 @@ async def metrics():
"""Metrics endpoint for monitoring""" """Metrics endpoint for monitoring"""
return {"metrics": "enabled"} return {"metrics": "enabled"}
# ================================================================
# SERVER-SENT EVENTS (SSE) HELPER FUNCTIONS
# ================================================================
def _get_subscription_channels(tenant_id: str, channel_filters: list) -> list:
"""
Determine which Redis channels to subscribe to based on filters.
Args:
tenant_id: Tenant identifier
channel_filters: List of channel patterns (e.g., ["inventory.alerts", "*.notifications"])
Returns:
List of full channel names to subscribe to
Examples:
>>> _get_subscription_channels("abc", ["inventory.alerts"])
["tenant:abc:inventory.alerts"]
>>> _get_subscription_channels("abc", ["*.alerts"])
["tenant:abc:inventory.alerts", "tenant:abc:production.alerts", ...]
>>> _get_subscription_channels("abc", [])
["tenant:abc:inventory.alerts", "tenant:abc:inventory.notifications", ...]
"""
all_domains = ["inventory", "production", "supply_chain", "demand", "operations"]
all_classes = ["alerts", "notifications"]
channels = []
if not channel_filters:
# Subscribe to ALL channels (backward compatible)
for domain in all_domains:
for event_class in all_classes:
channels.append(f"tenant:{tenant_id}:{domain}.{event_class}")
# Also subscribe to recommendations (tenant-wide)
channels.append(f"tenant:{tenant_id}:recommendations")
# Also subscribe to legacy channel for backward compatibility
channels.append(f"alerts:{tenant_id}")
return channels
# Parse filters and expand wildcards
for filter_pattern in channel_filters:
if filter_pattern == "*.*":
# All channels
for domain in all_domains:
for event_class in all_classes:
channels.append(f"tenant:{tenant_id}:{domain}.{event_class}")
channels.append(f"tenant:{tenant_id}:recommendations")
elif filter_pattern.endswith(".*"):
# Domain wildcard (e.g., "inventory.*")
domain = filter_pattern.split(".")[0]
for event_class in all_classes:
channels.append(f"tenant:{tenant_id}:{domain}.{event_class}")
elif filter_pattern.startswith("*."):
# Class wildcard (e.g., "*.alerts")
event_class = filter_pattern.split(".")[1]
if event_class == "recommendations":
channels.append(f"tenant:{tenant_id}:recommendations")
else:
for domain in all_domains:
channels.append(f"tenant:{tenant_id}:{domain}.{event_class}")
elif filter_pattern == "recommendations":
# Recommendations channel
channels.append(f"tenant:{tenant_id}:recommendations")
else:
# Specific channel (e.g., "inventory.alerts")
channels.append(f"tenant:{tenant_id}:{filter_pattern}")
return list(set(channels)) # Remove duplicates
async def _load_initial_state(redis_client, tenant_id: str, channel_filters: list) -> list:
"""
Load initial state from Redis cache based on channel filters.
Args:
redis_client: Redis client
tenant_id: Tenant identifier
channel_filters: List of channel patterns
Returns:
List of initial events
"""
initial_events = []
try:
if not channel_filters:
# Load from legacy cache if no filters (backward compat)
legacy_cache_key = f"active_alerts:{tenant_id}"
cached_data = await redis_client.get(legacy_cache_key)
if cached_data:
return json.loads(cached_data)
# Also try loading from new domain-specific caches
all_domains = ["inventory", "production", "supply_chain", "demand", "operations"]
all_classes = ["alerts", "notifications"]
for domain in all_domains:
for event_class in all_classes:
cache_key = f"active_events:{tenant_id}:{domain}.{event_class}s"
cached_data = await redis_client.get(cache_key)
if cached_data:
events = json.loads(cached_data)
initial_events.extend(events)
# Load recommendations
recommendations_cache_key = f"active_events:{tenant_id}:recommendations"
cached_data = await redis_client.get(recommendations_cache_key)
if cached_data:
initial_events.extend(json.loads(cached_data))
return initial_events
# Load based on specific filters
for filter_pattern in channel_filters:
# Extract domain and class from filter
if "." in filter_pattern:
parts = filter_pattern.split(".")
domain = parts[0] if parts[0] != "*" else None
event_class = parts[1] if len(parts) > 1 and parts[1] != "*" else None
if domain and event_class:
# Specific cache (e.g., "inventory.alerts")
cache_key = f"active_events:{tenant_id}:{domain}.{event_class}s"
cached_data = await redis_client.get(cache_key)
if cached_data:
initial_events.extend(json.loads(cached_data))
elif domain and not event_class:
# Domain wildcard (e.g., "inventory.*")
for ec in ["alerts", "notifications"]:
cache_key = f"active_events:{tenant_id}:{domain}.{ec}"
cached_data = await redis_client.get(cache_key)
if cached_data:
initial_events.extend(json.loads(cached_data))
elif not domain and event_class:
# Class wildcard (e.g., "*.alerts")
all_domains = ["inventory", "production", "supply_chain", "demand", "operations"]
for d in all_domains:
cache_key = f"active_events:{tenant_id}:{d}.{event_class}s"
cached_data = await redis_client.get(cache_key)
if cached_data:
initial_events.extend(json.loads(cached_data))
elif filter_pattern == "recommendations":
cache_key = f"active_events:{tenant_id}:recommendations"
cached_data = await redis_client.get(cache_key)
if cached_data:
initial_events.extend(json.loads(cached_data))
return initial_events
except Exception as e:
logger.error(f"Error loading initial state for tenant {tenant_id}: {e}")
return []
def _determine_event_type(event_data: dict) -> str:
"""
Determine SSE event type from event data.
Args:
event_data: Event data dictionary
Returns:
SSE event type: 'alert', 'notification', or 'recommendation'
"""
# New event architecture uses 'event_class'
if 'event_class' in event_data:
return event_data['event_class'] # 'alert', 'notification', or 'recommendation'
# Legacy format uses 'item_type'
if 'item_type' in event_data:
if event_data['item_type'] == 'recommendation':
return 'recommendation'
else:
return 'alert'
# Default to 'alert' for backward compatibility
return 'alert'
# ================================================================ # ================================================================
# SERVER-SENT EVENTS (SSE) ENDPOINT # SERVER-SENT EVENTS (SSE) ENDPOINT
# ================================================================ # ================================================================
@app.get("/api/events") @app.get("/api/events")
async def events_stream(request: Request, tenant_id: str): async def events_stream(
request: Request,
tenant_id: str,
channels: str = None # Comma-separated channel filters (e.g., "inventory.alerts,production.notifications")
):
""" """
Server-Sent Events stream for real-time notifications. Server-Sent Events stream for real-time notifications with multi-channel support.
Authentication is handled by auth middleware via query param token. Authentication is handled by auth middleware via query param token.
User context is available in request.state.user (injected by middleware). User context is available in request.state.user (injected by middleware).
Tenant ID is provided by the frontend as a query parameter.
Query Parameters:
tenant_id: Tenant identifier (required)
channels: Comma-separated channel filters (optional)
Examples:
- "inventory.alerts,production.notifications" - Specific channels
- "*.alerts" - All alert channels
- "inventory.*" - All inventory events
- None - All channels (default, backward compatible)
New channel pattern: tenant:{tenant_id}:{domain}.{class}
Examples:
- tenant:abc:inventory.alerts
- tenant:abc:production.notifications
- tenant:abc:recommendations
Legacy channel (backward compat): alerts:{tenant_id}
""" """
global redis_client global redis_client
@@ -171,40 +379,45 @@ async def events_stream(request: Request, tenant_id: str):
if not tenant_id: if not tenant_id:
raise HTTPException(status_code=400, detail="tenant_id query parameter is required") raise HTTPException(status_code=400, detail="tenant_id query parameter is required")
logger.info(f"SSE connection request for user {email}, tenant {tenant_id}") # Parse channel filters
channel_filters = []
if channels:
channel_filters = [c.strip() for c in channels.split(',') if c.strip()]
logger.info(f"SSE connection established for tenant: {tenant_id}") logger.info(f"SSE connection request for user {email}, tenant {tenant_id}, channels: {channel_filters or 'all'}")
async def event_generator(): async def event_generator():
"""Generate server-sent events from Redis pub/sub""" """Generate server-sent events from Redis pub/sub with multi-channel support"""
pubsub = None pubsub = None
try: try:
# Subscribe to tenant-specific alert channel
pubsub = redis_client.pubsub() pubsub = redis_client.pubsub()
channel_name = f"alerts:{tenant_id}"
await pubsub.subscribe(channel_name) # Determine which channels to subscribe to
subscription_channels = _get_subscription_channels(tenant_id, channel_filters)
# Subscribe to all determined channels
if subscription_channels:
await pubsub.subscribe(*subscription_channels)
logger.info(f"Subscribed to {len(subscription_channels)} channels for tenant {tenant_id}")
else:
# Fallback to legacy channel if no channels specified
legacy_channel = f"alerts:{tenant_id}"
await pubsub.subscribe(legacy_channel)
logger.info(f"Subscribed to legacy channel: {legacy_channel}")
# Send initial connection event # Send initial connection event
yield f"event: connection\n" yield f"event: connection\n"
yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established', 'timestamp': time.time()})}\n\n" yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established', 'channels': subscription_channels or ['all'], 'timestamp': time.time()})}\n\n"
# Fetch and send initial active alerts from Redis cache # Fetch and send initial state from cache (domain-specific or legacy)
try: initial_events = await _load_initial_state(redis_client, tenant_id, channel_filters)
cache_key = f"active_alerts:{tenant_id}" if initial_events:
cached_alerts = await redis_client.get(cache_key) logger.info(f"Sending {len(initial_events)} initial events to tenant {tenant_id}")
if cached_alerts: yield f"event: initial_state\n"
active_items = json.loads(cached_alerts) yield f"data: {json.dumps(initial_events)}\n\n"
logger.info(f"Sending initial_items to tenant {tenant_id}, count: {len(active_items)}") else:
yield f"event: initial_items\n" # Send empty initial state for compatibility
yield f"data: {json.dumps(active_items)}\n\n" yield f"event: initial_state\n"
else:
logger.info(f"No cached alerts found for tenant {tenant_id}")
yield f"event: initial_items\n"
yield f"data: {json.dumps([])}\n\n"
except Exception as e:
logger.error(f"Error fetching initial items for tenant {tenant_id}: {e}")
# Still send empty initial_items event
yield f"event: initial_items\n"
yield f"data: {json.dumps([])}\n\n" yield f"data: {json.dumps([])}\n\n"
heartbeat_counter = 0 heartbeat_counter = 0
@@ -220,23 +433,19 @@ async def events_stream(request: Request, tenant_id: str):
message = await asyncio.wait_for(pubsub.get_message(ignore_subscribe_messages=True), timeout=10.0) message = await asyncio.wait_for(pubsub.get_message(ignore_subscribe_messages=True), timeout=10.0)
if message and message['type'] == 'message': if message and message['type'] == 'message':
# Forward the alert/notification from Redis # Forward the event from Redis
alert_data = json.loads(message['data']) event_data = json.loads(message['data'])
# Determine event type based on alert data # Determine event type for SSE
event_type = "notification" event_type = _determine_event_type(event_data)
if alert_data.get('item_type') == 'alert':
if alert_data.get('severity') in ['high', 'urgent']: # Add channel metadata for frontend routing
event_type = "inventory_alert" event_data['_channel'] = message['channel'].decode('utf-8') if isinstance(message['channel'], bytes) else message['channel']
else:
event_type = "notification"
elif alert_data.get('item_type') == 'recommendation':
event_type = "notification"
yield f"event: {event_type}\n" yield f"event: {event_type}\n"
yield f"data: {json.dumps(alert_data)}\n\n" yield f"data: {json.dumps(event_data)}\n\n"
logger.debug(f"SSE message sent to tenant {tenant_id}: {alert_data.get('title')}") logger.debug(f"SSE event sent to tenant {tenant_id}: {event_type} - {event_data.get('title')}")
except asyncio.TimeoutError: except asyncio.TimeoutError:
# Send heartbeat every 10 timeouts (100 seconds) # Send heartbeat every 10 timeouts (100 seconds)
@@ -249,7 +458,7 @@ async def events_stream(request: Request, tenant_id: str):
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"SSE connection cancelled for tenant: {tenant_id}") logger.info(f"SSE connection cancelled for tenant: {tenant_id}")
except Exception as e: except Exception as e:
logger.error(f"SSE error for tenant {tenant_id}: {e}") logger.error(f"SSE error for tenant {tenant_id}: {e}", exc_info=True)
finally: finally:
if pubsub: if pubsub:
await pubsub.unsubscribe() await pubsub.unsubscribe()

View File

@@ -247,6 +247,13 @@ async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...
# TENANT-SCOPED ALERT ANALYTICS ENDPOINTS (Must come BEFORE inventory alerts) # TENANT-SCOPED ALERT ANALYTICS ENDPOINTS (Must come BEFORE inventory alerts)
# ================================================================ # ================================================================
# Exact match for /alerts endpoint (without additional path)
@router.api_route("/{tenant_id}/alerts", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alerts_list(request: Request, tenant_id: str = Path(...)):
"""Proxy tenant alerts list requests to alert processor service"""
target_path = f"/api/v1/tenants/{tenant_id}/alerts"
return await _proxy_to_alert_processor_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/alerts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) @router.api_route("/{tenant_id}/alerts/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alert_analytics(request: Request, tenant_id: str = Path(...), path: str = ""): async def proxy_tenant_alert_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant alert analytics requests to alert processor service""" """Proxy tenant alert analytics requests to alert processor service"""

View File

@@ -41,7 +41,7 @@ spec:
cpu: "500m" cpu: "500m"
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /health
port: 3000 port: 3000
initialDelaySeconds: 60 initialDelaySeconds: 60
timeoutSeconds: 10 timeoutSeconds: 10
@@ -49,7 +49,7 @@ spec:
failureThreshold: 3 failureThreshold: 3
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /health
port: 3000 port: 3000
initialDelaySeconds: 20 initialDelaySeconds: 20
timeoutSeconds: 5 timeoutSeconds: 5

View File

@@ -187,6 +187,33 @@ data:
ALERT_DEDUPLICATION_WINDOW_MINUTES: "15" ALERT_DEDUPLICATION_WINDOW_MINUTES: "15"
RECOMMENDATION_DEDUPLICATION_WINDOW_MINUTES: "60" RECOMMENDATION_DEDUPLICATION_WINDOW_MINUTES: "60"
# Alert Enrichment Configuration (Unified Alert Service)
# Priority scoring weights (must sum to 1.0)
BUSINESS_IMPACT_WEIGHT: "0.4"
URGENCY_WEIGHT: "0.3"
USER_AGENCY_WEIGHT: "0.2"
CONFIDENCE_WEIGHT: "0.1"
# Priority thresholds (0-100 scale)
CRITICAL_THRESHOLD: "90"
IMPORTANT_THRESHOLD: "70"
STANDARD_THRESHOLD: "50"
# Timing intelligence
BUSINESS_HOURS_START: "6"
BUSINESS_HOURS_END: "22"
PEAK_HOURS_START: "7"
PEAK_HOURS_END: "11"
PEAK_HOURS_EVENING_START: "17"
PEAK_HOURS_EVENING_END: "19"
# Alert grouping
GROUPING_TIME_WINDOW_MINUTES: "15"
MAX_ALERTS_PER_GROUP: "5"
# Email digest
DIGEST_SEND_TIME: "18:00"
# ================================================================ # ================================================================
# CHECK FREQUENCIES (CRON EXPRESSIONS) # CHECK FREQUENCIES (CRON EXPRESSIONS)
# ================================================================ # ================================================================

View File

@@ -0,0 +1,120 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: alert-priority-recalculation
namespace: bakery-ia
labels:
app: alert-priority-recalculation
component: cron
service: alert-processor
spec:
# Schedule: Every hour at minute 15
schedule: "15 * * * *"
# Keep last 3 successful jobs and 1 failed job for debugging
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
# Don't start new job if previous one is still running
concurrencyPolicy: Forbid
# Job must complete within 10 minutes
startingDeadlineSeconds: 600
jobTemplate:
spec:
# Retry up to 2 times if job fails
backoffLimit: 2
# Job must complete within 30 minutes
activeDeadlineSeconds: 1800
template:
metadata:
labels:
app: alert-priority-recalculation
component: cron
spec:
restartPolicy: OnFailure
# Use alert-processor service image
containers:
- name: priority-recalc
image: bakery/alert-processor:latest
imagePullPolicy: Always
command:
- python3
- -m
- app.jobs.priority_recalculation
env:
# Database connection
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-secrets
key: ALERT_PROCESSOR_DATABASE_URL
# Redis connection
- name: REDIS_URL
value: rediss://redis-service:6379/0?ssl_cert_reqs=none
# Alert processor settings
- name: BUSINESS_IMPACT_WEIGHT
value: "0.40"
- name: URGENCY_WEIGHT
value: "0.30"
- name: USER_AGENCY_WEIGHT
value: "0.20"
- name: CONFIDENCE_WEIGHT
value: "0.10"
- name: CRITICAL_THRESHOLD
value: "90"
- name: IMPORTANT_THRESHOLD
value: "70"
- name: STANDARD_THRESHOLD
value: "50"
# Escalation thresholds (hours)
- name: ESCALATION_THRESHOLD_48H
value: "48"
- name: ESCALATION_THRESHOLD_72H
value: "72"
# Service settings
- name: LOG_LEVEL
value: "INFO"
- name: PYTHONUNBUFFERED
value: "1"
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: alert-priority-recalculation-config
namespace: bakery-ia
data:
schedule: "Hourly at minute 15"
description: "Recalculates alert priorities with time-based escalation"
escalation_48h_boost: "10"
escalation_72h_boost: "20"
deadline_24h_boost: "15"
deadline_6h_boost: "30"
max_boost: "30"

View File

@@ -0,0 +1,176 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: delivery-tracking
namespace: bakery-ia
labels:
app: delivery-tracking
component: cron
service: orchestrator
spec:
# Schedule: Every hour at minute 30
schedule: "30 * * * *"
# Keep last 3 successful jobs and 1 failed job for debugging
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
# Don't start new job if previous one is still running
concurrencyPolicy: Forbid
# Job must complete within 10 minutes
startingDeadlineSeconds: 600
jobTemplate:
spec:
# Retry up to 2 times if job fails
backoffLimit: 2
# Job must complete within 30 minutes
activeDeadlineSeconds: 1800
template:
metadata:
labels:
app: delivery-tracking
component: cron
spec:
restartPolicy: OnFailure
# Use orchestrator service image
containers:
- name: delivery-tracker
image: bakery/orchestrator-service:latest
imagePullPolicy: Always
command:
- python3
- -c
- |
import asyncio
import os
from app.services.delivery_tracking_service import DeliveryTrackingService
from shared.database.base import create_database_manager
from app.core.config import settings
from shared.messaging.rabbitmq import RabbitMQClient
import structlog
logger = structlog.get_logger()
async def run_delivery_tracking():
"""Run delivery tracking for all tenants"""
import redis.asyncio as redis
from shared.redis_utils import initialize_redis, get_redis_client
config = settings # Use the global settings instance
db_manager = create_database_manager(config.DATABASE_URL, "orchestrator")
try:
# Initialize Redis - This is an async function
await initialize_redis(config.REDIS_URL, db=2, max_connections=10) # Using db 2 for orchestrator
redis_client = await get_redis_client()
except Exception as e:
logger.error("Failed to initialize Redis", error=str(e))
raise
try:
rabbitmq_client = RabbitMQClient(config.RABBITMQ_URL, "delivery-tracking-job")
service = DeliveryTrackingService(
config=config,
db_manager=db_manager,
redis_client=redis_client,
rabbitmq_client=rabbitmq_client
)
logger.info("Starting delivery tracking job")
# Get active tenant IDs from environment variable
active_tenant_ids = os.environ.get('ACTIVE_TENANT_IDS', '')
if active_tenant_ids:
tenant_ids = [tid.strip() for tid in active_tenant_ids.split(',') if tid.strip()]
else:
tenant_ids = ['00000000-0000-0000-0000-000000000001'] # Default single tenant
for tenant_id in tenant_ids:
try:
result = await service.check_expected_deliveries(tenant_id)
logger.info("Delivery tracking completed", tenant_id=tenant_id, **result)
except Exception as e:
logger.error("Delivery tracking failed", tenant_id=tenant_id, error=str(e))
logger.info("Delivery tracking job completed")
except Exception as e:
logger.error("Delivery tracking service error", error=str(e))
raise
if __name__ == "__main__":
asyncio.run(run_delivery_tracking())
env:
# Database connection
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-secrets
key: ORCHESTRATOR_DATABASE_URL
# Redis connection
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: database-secrets
key: REDIS_URL
# Service URLs
- name: ALERT_PROCESSOR_URL
value: "http://alert-processor-api:8000"
- name: PROCUREMENT_SERVICE_URL
value: "http://procurement-service:8000"
# Active tenants (comma-separated UUIDs)
- name: ACTIVE_TENANT_IDS
value: "00000000-0000-0000-0000-000000000001"
# Orchestrator settings
- name: ORCHESTRATOR_CONTEXT_CACHE_TTL
value: "300"
# Delivery tracking settings
- name: ARRIVING_SOON_HOURS_BEFORE
value: "2"
- name: OVERDUE_MINUTES_AFTER
value: "30"
- name: DEFAULT_DELIVERY_WINDOW_HOURS
value: "4"
# Service settings
- name: LOG_LEVEL
value: "INFO"
- name: PYTHONUNBUFFERED
value: "1"
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: delivery-tracking-config
namespace: bakery-ia
data:
schedule: "Hourly at minute 30"
description: "Checks expected deliveries and generates proactive alerts"
arriving_soon_hours: "2"
overdue_minutes: "30"
delivery_window_hours: "4"

View File

@@ -0,0 +1,67 @@
apiVersion: batch/v1
kind: Job
metadata:
name: demo-seed-alerts
namespace: bakery-ia
labels:
app: demo-seed
component: initialization
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "28" # After orchestration runs (27), as alerts reference recent data
spec:
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app: demo-seed-alerts
spec:
initContainers:
- name: wait-for-alert-processor-migration
image: busybox:1.36
command:
- sh
- -c
- |
echo "Waiting 30 seconds for alert-processor-migration to complete..."
sleep 30
- name: wait-for-alert-processor-api
image: curlimages/curl:latest
command:
- sh
- -c
- |
echo "Waiting for alert-processor-api to be ready..."
until curl -f http://alert-processor-api.bakery-ia.svc.cluster.local:8010/health > /dev/null 2>&1; do
echo "alert-processor-api not ready yet, waiting..."
sleep 5
done
echo "alert-processor-api is ready!"
containers:
- name: seed-alerts
image: bakery/alert-processor:latest
command: ["python", "/app/scripts/demo/seed_demo_alerts.py"]
env:
- name: ALERT_PROCESSOR_DATABASE_URL
valueFrom:
secretKeyRef:
name: database-secrets
key: ALERT_PROCESSOR_DATABASE_URL
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database-secrets
key: ALERT_PROCESSOR_DATABASE_URL
- name: DEMO_MODE
value: "production"
- name: LOG_LEVEL
value: "INFO"
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
restartPolicy: OnFailure
serviceAccountName: demo-seed-sa

View File

@@ -62,6 +62,7 @@ resources:
- jobs/demo-seed-forecasts-job.yaml - jobs/demo-seed-forecasts-job.yaml
- jobs/demo-seed-pos-configs-job.yaml - jobs/demo-seed-pos-configs-job.yaml
- jobs/demo-seed-orchestration-runs-job.yaml - jobs/demo-seed-orchestration-runs-job.yaml
- jobs/demo-seed-alerts-job.yaml
# External data initialization job (v2.0) # External data initialization job (v2.0)
- jobs/external-data-init-job.yaml - jobs/external-data-init-job.yaml
@@ -70,6 +71,8 @@ resources:
- cronjobs/demo-cleanup-cronjob.yaml - cronjobs/demo-cleanup-cronjob.yaml
- cronjobs/external-data-rotation-cronjob.yaml - cronjobs/external-data-rotation-cronjob.yaml
- cronjobs/usage-tracker-cronjob.yaml - cronjobs/usage-tracker-cronjob.yaml
- cronjobs/alert-priority-recalculation-cronjob.yaml
- cronjobs/delivery-tracking-cronjob.yaml
# Infrastructure components # Infrastructure components
- components/databases/redis.yaml - components/databases/redis.yaml

View File

@@ -172,11 +172,11 @@ patches:
path: /spec/template/spec/containers/0/resources path: /spec/template/spec/containers/0/resources
value: value:
requests: requests:
memory: "64Mi" memory: "512Mi"
cpu: "25m" cpu: "200m"
limits: limits:
memory: "128Mi" memory: "1Gi"
cpu: "100m" cpu: "1000m"
- target: - target:
group: apps group: apps
version: v1 version: v1

View File

@@ -3,14 +3,18 @@ apiVersion: kind.x-k8s.io/v1alpha4
name: bakery-ia-local name: bakery-ia-local
nodes: nodes:
- role: control-plane - role: control-plane
# Increase resource limits for the Kind node to handle multiple services
kubeadmConfigPatches: kubeadmConfigPatches:
- | - |
kind: InitConfiguration kind: InitConfiguration
nodeRegistration: nodeRegistration:
kubeletExtraArgs: kubeletExtraArgs:
node-labels: "ingress-ready=true" node-labels: "ingress-ready=true"
# Increase max pods for development environment
max-pods: "200"
- | - |
kind: ClusterConfiguration kind: ClusterConfiguration
# Increase API server memory and other parameters for local dev
apiServer: apiServer:
extraArgs: extraArgs:
encryption-provider-config: /etc/kubernetes/enc/encryption-config.yaml encryption-provider-config: /etc/kubernetes/enc/encryption-config.yaml
@@ -20,10 +24,12 @@ nodes:
mountPath: /etc/kubernetes/enc mountPath: /etc/kubernetes/enc
readOnly: true readOnly: true
pathType: DirectoryOrCreate pathType: DirectoryOrCreate
# Mount encryption keys for secure development
extraMounts: extraMounts:
- hostPath: ./infrastructure/kubernetes/encryption - hostPath: ./infrastructure/kubernetes/encryption
containerPath: /etc/kubernetes/enc containerPath: /etc/kubernetes/enc
readOnly: true readOnly: true
# Port mappings for local access
extraPortMappings: extraPortMappings:
# HTTP ingress # HTTP ingress
- containerPort: 30080 - containerPort: 30080

View File

@@ -149,6 +149,11 @@ run_seed "production" "seed_demo_quality_templates.py" "Seeding quality check te
# ============================================================================ # ============================================================================
run_seed "forecasting" "seed_demo_forecasts.py" "Seeding demand forecasts" run_seed "forecasting" "seed_demo_forecasts.py" "Seeding demand forecasts"
# ============================================================================
# Phase 9: Orchestration Runs
# ============================================================================
run_seed "orchestrator" "seed_demo_orchestration_runs.py" "Seeding orchestration runs with reasoning"
# ============================================================================ # ============================================================================
# Summary # Summary
# ============================================================================ # ============================================================================

View File

@@ -4,5 +4,6 @@ Alert Processor API Endpoints
from .analytics import router as analytics_router from .analytics import router as analytics_router
from .alerts import router as alerts_router from .alerts import router as alerts_router
from .internal_demo import router as internal_demo_router
__all__ = ['analytics_router', 'alerts_router'] __all__ = ['analytics_router', 'alerts_router', 'internal_demo_router']

View File

@@ -3,7 +3,7 @@
Alerts API endpoints for dashboard and alert management Alerts API endpoints for dashboard and alert management
""" """
from fastapi import APIRouter, HTTPException, Query, Path from fastapi import APIRouter, HTTPException, Query, Path, Depends
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from uuid import UUID from uuid import UUID
@@ -11,7 +11,8 @@ from datetime import datetime
import structlog import structlog
from app.repositories.alerts_repository import AlertsRepository from app.repositories.alerts_repository import AlertsRepository
from app.models.alerts import AlertSeverity, AlertStatus from app.models.events import AlertStatus
from app.dependencies import get_current_user
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -28,12 +29,14 @@ class AlertResponse(BaseModel):
tenant_id: str tenant_id: str
item_type: str item_type: str
alert_type: str alert_type: str
severity: str priority_level: str
priority_score: int
status: str status: str
service: str service: str
title: str title: str
message: str message: str
actions: Optional[dict] = None type_class: str
actions: Optional[List[dict]] = None # smart_actions is a list of action objects
alert_metadata: Optional[dict] = None alert_metadata: Optional[dict] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -47,10 +50,10 @@ class AlertsSummaryResponse(BaseModel):
"""Alerts summary for dashboard""" """Alerts summary for dashboard"""
total_count: int = Field(..., description="Total number of alerts") total_count: int = Field(..., description="Total number of alerts")
active_count: int = Field(..., description="Number of active (unresolved) alerts") active_count: int = Field(..., description="Number of active (unresolved) alerts")
critical_count: int = Field(..., description="Number of critical/urgent alerts") critical_count: int = Field(..., description="Number of critical priority alerts")
high_count: int = Field(..., description="Number of high severity alerts") high_count: int = Field(..., description="Number of high priority alerts")
medium_count: int = Field(..., description="Number of medium severity alerts") medium_count: int = Field(..., description="Number of medium priority alerts")
low_count: int = Field(..., description="Number of low severity alerts") low_count: int = Field(..., description="Number of low priority alerts")
resolved_count: int = Field(..., description="Number of resolved alerts") resolved_count: int = Field(..., description="Number of resolved alerts")
acknowledged_count: int = Field(..., description="Number of acknowledged alerts") acknowledged_count: int = Field(..., description="Number of acknowledged alerts")
@@ -71,7 +74,7 @@ class AlertsListResponse(BaseModel):
"/api/v1/tenants/{tenant_id}/alerts/summary", "/api/v1/tenants/{tenant_id}/alerts/summary",
response_model=AlertsSummaryResponse, response_model=AlertsSummaryResponse,
summary="Get alerts summary", summary="Get alerts summary",
description="Get summary of alerts by severity and status for dashboard health indicator" description="Get summary of alerts by priority level and status for dashboard health indicator"
) )
async def get_alerts_summary( async def get_alerts_summary(
tenant_id: UUID = Path(..., description="Tenant ID") tenant_id: UUID = Path(..., description="Tenant ID")
@@ -79,8 +82,8 @@ async def get_alerts_summary(
""" """
Get alerts summary for dashboard Get alerts summary for dashboard
Returns counts of alerts grouped by severity and status. Returns counts of alerts grouped by priority level and status.
Critical count maps to URGENT severity for dashboard compatibility. Critical count maps to URGENT priority level for dashboard compatibility.
""" """
from app.config import AlertProcessorConfig from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager from shared.database.base import create_database_manager
@@ -107,7 +110,7 @@ async def get_alerts_summary(
) )
async def get_alerts( async def get_alerts(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
severity: Optional[str] = Query(None, description="Filter by severity: low, medium, high, urgent"), priority_level: Optional[str] = Query(None, description="Filter by priority level: critical, important, standard, info"),
status: Optional[str] = Query(None, description="Filter by status: active, resolved, acknowledged, ignored"), status: Optional[str] = Query(None, description="Filter by status: active, resolved, acknowledged, ignored"),
resolved: Optional[bool] = Query(None, description="Filter by resolved status: true=resolved only, false=unresolved only"), resolved: Optional[bool] = Query(None, description="Filter by resolved status: true=resolved only, false=unresolved only"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"), limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"),
@@ -117,7 +120,7 @@ async def get_alerts(
Get filtered list of alerts Get filtered list of alerts
Supports filtering by: Supports filtering by:
- severity: low, medium, high, urgent (maps to "critical" in dashboard) - priority_level: critical, important, standard, info
- status: active, resolved, acknowledged, ignored - status: active, resolved, acknowledged, ignored
- resolved: boolean filter for resolved status - resolved: boolean filter for resolved status
- pagination: limit and offset - pagination: limit and offset
@@ -126,18 +129,20 @@ async def get_alerts(
from shared.database.base import create_database_manager from shared.database.base import create_database_manager
try: try:
# Validate severity enum # Validate priority_level enum
if severity and severity not in [s.value for s in AlertSeverity]: valid_priority_levels = ['critical', 'important', 'standard', 'info']
if priority_level and priority_level not in valid_priority_levels:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid severity. Must be one of: {[s.value for s in AlertSeverity]}" detail=f"Invalid priority level. Must be one of: {valid_priority_levels}"
) )
# Validate status enum # Validate status enum
if status and status not in [s.value for s in AlertStatus]: valid_status_values = ['active', 'resolved', 'acknowledged', 'ignored']
if status and status not in valid_status_values:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid status. Must be one of: {[s.value for s in AlertStatus]}" detail=f"Invalid status. Must be one of: {valid_status_values}"
) )
config = AlertProcessorConfig() config = AlertProcessorConfig()
@@ -147,7 +152,7 @@ async def get_alerts(
repo = AlertsRepository(session) repo = AlertsRepository(session)
alerts = await repo.get_alerts( alerts = await repo.get_alerts(
tenant_id=tenant_id, tenant_id=tenant_id,
severity=severity, priority_level=priority_level,
status=status, status=status,
resolved=resolved, resolved=resolved,
limit=limit, limit=limit,
@@ -155,25 +160,42 @@ async def get_alerts(
) )
# Convert to response models # Convert to response models
alert_responses = [ alert_responses = []
AlertResponse( for alert in alerts:
# Handle old format actions (strings) by converting to proper dict format
actions = alert.smart_actions
if actions and isinstance(actions, list) and len(actions) > 0:
# Check if actions are strings (old format)
if isinstance(actions[0], str):
# Convert old format to new format
actions = [
{
'action_type': action,
'label': action.replace('_', ' ').title(),
'variant': 'default',
'disabled': False
}
for action in actions
]
alert_responses.append(AlertResponse(
id=str(alert.id), id=str(alert.id),
tenant_id=str(alert.tenant_id), tenant_id=str(alert.tenant_id),
item_type=alert.item_type, item_type=alert.item_type,
alert_type=alert.alert_type, alert_type=alert.alert_type,
severity=alert.severity, priority_level=alert.priority_level.value if hasattr(alert.priority_level, 'value') else alert.priority_level,
status=alert.status, priority_score=alert.priority_score,
status=alert.status.value if hasattr(alert.status, 'value') else alert.status,
service=alert.service, service=alert.service,
title=alert.title, title=alert.title,
message=alert.message, message=alert.message,
actions=alert.actions, type_class=alert.type_class.value if hasattr(alert.type_class, 'value') else alert.type_class,
actions=actions, # Use converted actions
alert_metadata=alert.alert_metadata, alert_metadata=alert.alert_metadata,
created_at=alert.created_at, created_at=alert.created_at,
updated_at=alert.updated_at, updated_at=alert.updated_at,
resolved_at=alert.resolved_at resolved_at=alert.resolved_at
) ))
for alert in alerts
]
return AlertsListResponse( return AlertsListResponse(
alerts=alert_responses, alerts=alert_responses,
@@ -214,17 +236,35 @@ async def get_alert(
if not alert: if not alert:
raise HTTPException(status_code=404, detail="Alert not found") raise HTTPException(status_code=404, detail="Alert not found")
# Handle old format actions (strings) by converting to proper dict format
actions = alert.smart_actions
if actions and isinstance(actions, list) and len(actions) > 0:
# Check if actions are strings (old format)
if isinstance(actions[0], str):
# Convert old format to new format
actions = [
{
'action_type': action,
'label': action.replace('_', ' ').title(),
'variant': 'default',
'disabled': False
}
for action in actions
]
return AlertResponse( return AlertResponse(
id=str(alert.id), id=str(alert.id),
tenant_id=str(alert.tenant_id), tenant_id=str(alert.tenant_id),
item_type=alert.item_type, item_type=alert.item_type,
alert_type=alert.alert_type, alert_type=alert.alert_type,
severity=alert.severity, priority_level=alert.priority_level.value if hasattr(alert.priority_level, 'value') else alert.priority_level,
status=alert.status, priority_score=alert.priority_score,
status=alert.status.value if hasattr(alert.status, 'value') else alert.status,
service=alert.service, service=alert.service,
title=alert.title, title=alert.title,
message=alert.message, message=alert.message,
actions=alert.actions, type_class=alert.type_class.value if hasattr(alert.type_class, 'value') else alert.type_class,
actions=actions, # Use converted actions
alert_metadata=alert.alert_metadata, alert_metadata=alert.alert_metadata,
created_at=alert.created_at, created_at=alert.created_at,
updated_at=alert.updated_at, updated_at=alert.updated_at,
@@ -236,3 +276,242 @@ async def get_alert(
except Exception as e: except Exception as e:
logger.error("Error getting alert", error=str(e), alert_id=str(alert_id)) logger.error("Error getting alert", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/cancel-auto-action",
summary="Cancel auto-action for escalation alert",
description="Cancel the pending auto-action for an escalation-type alert"
)
async def cancel_auto_action(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID")
) -> dict:
"""
Cancel the auto-action scheduled for an escalation alert.
This prevents the system from automatically executing the action.
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import AlertStatus
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
alert = await repo.get_alert_by_id(tenant_id, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
# Verify this is an escalation alert
if alert.type_class != 'escalation':
raise HTTPException(
status_code=400,
detail="Alert is not an escalation type, no auto-action to cancel"
)
# Update alert metadata to mark auto-action as cancelled
alert.alert_metadata = alert.alert_metadata or {}
alert.alert_metadata['auto_action_cancelled'] = True
alert.alert_metadata['auto_action_cancelled_at'] = datetime.utcnow().isoformat()
# Update urgency context to remove countdown
if alert.urgency_context:
alert.urgency_context['auto_action_countdown_seconds'] = None
alert.urgency_context['auto_action_cancelled'] = True
# Change type class from escalation to action_needed
alert.type_class = 'action_needed'
await session.commit()
await session.refresh(alert)
logger.info("Auto-action cancelled", alert_id=str(alert_id), tenant_id=str(tenant_id))
return {
"success": True,
"alert_id": str(alert_id),
"message": "Auto-action cancelled successfully",
"updated_type_class": alert.type_class.value
}
except HTTPException:
raise
except Exception as e:
logger.error("Error cancelling auto-action", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/acknowledge",
summary="Acknowledge alert",
description="Mark alert as acknowledged"
)
async def acknowledge_alert(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID")
) -> dict:
"""Mark an alert as acknowledged"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import AlertStatus
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
alert = await repo.get_alert_by_id(tenant_id, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
alert.status = AlertStatus.ACKNOWLEDGED
await session.commit()
logger.info("Alert acknowledged", alert_id=str(alert_id), tenant_id=str(tenant_id))
return {
"success": True,
"alert_id": str(alert_id),
"status": alert.status.value
}
except HTTPException:
raise
except Exception as e:
logger.error("Error acknowledging alert", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/{alert_id}/resolve",
summary="Resolve alert",
description="Mark alert as resolved"
)
async def resolve_alert(
tenant_id: UUID = Path(..., description="Tenant ID"),
alert_id: UUID = Path(..., description="Alert ID")
) -> dict:
"""Mark an alert as resolved"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import AlertStatus
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
repo = AlertsRepository(session)
alert = await repo.get_alert_by_id(tenant_id, alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
alert.status = AlertStatus.RESOLVED
alert.resolved_at = datetime.utcnow()
await session.commit()
logger.info("Alert resolved", alert_id=str(alert_id), tenant_id=str(tenant_id))
return {
"success": True,
"alert_id": str(alert_id),
"status": alert.status.value,
"resolved_at": alert.resolved_at.isoformat()
}
except HTTPException:
raise
except Exception as e:
logger.error("Error resolving alert", error=str(e), alert_id=str(alert_id))
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/api/v1/tenants/{tenant_id}/alerts/digest/send",
summary="Send email digest for alerts"
)
async def send_alert_digest(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(1, ge=1, le=7, description="Number of days to include in digest"),
digest_type: str = Query("daily", description="Type of digest: daily or weekly"),
user_email: str = Query(..., description="Email address to send digest to"),
user_name: str = Query(None, description="User name for personalization"),
current_user: dict = Depends(get_current_user)
):
"""
Send email digest of alerts.
Digest includes:
- AI Impact Summary (prevented issues, savings)
- Prevented Issues List with AI reasoning
- Action Needed Alerts
- Trend Warnings
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import Alert
from app.services.enrichment.email_digest import EmailDigestService
from sqlalchemy import select, and_
from datetime import datetime, timedelta
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
cutoff_date = datetime.utcnow() - timedelta(days=days)
# Fetch alerts from the specified period
query = select(Alert).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).order_by(Alert.created_at.desc())
result = await session.execute(query)
alerts = result.scalars().all()
if not alerts:
return {
"success": False,
"message": "No alerts found for the specified period",
"alert_count": 0
}
# Send digest
digest_service = EmailDigestService(config)
if digest_type == "weekly":
success = await digest_service.send_weekly_digest(
tenant_id=tenant_id,
alerts=alerts,
user_email=user_email,
user_name=user_name
)
else:
success = await digest_service.send_daily_digest(
tenant_id=tenant_id,
alerts=alerts,
user_email=user_email,
user_name=user_name
)
return {
"success": success,
"message": f"{'Successfully sent' if success else 'Failed to send'} {digest_type} digest",
"alert_count": len(alerts),
"digest_type": digest_type,
"recipient": user_email
}
except Exception as e:
logger.error("Error sending email digest", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to send email digest: {str(e)}")

View File

@@ -239,6 +239,166 @@ async def get_trends(
raise HTTPException(status_code=500, detail=f"Failed to get trends: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to get trends: {str(e)}")
@router.get(
"/api/v1/tenants/{tenant_id}/alerts/analytics/dashboard",
response_model=Dict[str, Any],
summary="Get enriched alert analytics for dashboard"
)
async def get_dashboard_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(30, ge=1, le=90, description="Number of days to analyze"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Get enriched alert analytics optimized for dashboard display.
Returns metrics based on the new enrichment system:
- AI handling rate (% of prevented_issue alerts)
- Priority distribution (critical, important, standard, info)
- Type class breakdown (action_needed, prevented_issue, trend_warning, etc.)
- Total financial impact at risk
- Average response time by priority level
- Prevented issues and estimated savings
"""
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.models.events import Alert, AlertStatus, AlertTypeClass, PriorityLevel
from sqlalchemy import select, func, and_
from datetime import datetime, timedelta
try:
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
async with db_manager.get_session() as session:
cutoff_date = datetime.utcnow() - timedelta(days=days)
# Total alerts
total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
)
total_result = await session.execute(total_query)
total_alerts = total_result.scalar() or 0
# Priority distribution
priority_query = select(
Alert.priority_level,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).group_by(Alert.priority_level)
priority_result = await session.execute(priority_query)
priority_dist = {row.priority_level: row.count for row in priority_result}
# Type class distribution
type_class_query = select(
Alert.type_class,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= cutoff_date
)
).group_by(Alert.type_class)
type_class_result = await session.execute(type_class_query)
type_class_dist = {row.type_class: row.count for row in type_class_result}
# AI handling metrics
prevented_count = type_class_dist.get(AlertTypeClass.PREVENTED_ISSUE, 0)
ai_handling_percentage = (prevented_count / total_alerts * 100) if total_alerts > 0 else 0
# Financial impact - sum all business_impact.financial_impact_eur from active alerts
active_alerts_query = select(Alert.id, Alert.business_impact).where(
and_(
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE
)
)
active_alerts_result = await session.execute(active_alerts_query)
active_alerts = active_alerts_result.all()
total_financial_impact = sum(
(alert.business_impact or {}).get('financial_impact_eur', 0)
for alert in active_alerts
)
# Prevented issues savings
prevented_alerts_query = select(Alert.id, Alert.orchestrator_context).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= cutoff_date
)
)
prevented_alerts_result = await session.execute(prevented_alerts_query)
prevented_alerts = prevented_alerts_result.all()
estimated_savings = sum(
(alert.orchestrator_context or {}).get('estimated_savings_eur', 0)
for alert in prevented_alerts
)
# Active alerts by type class
active_by_type_query = select(
Alert.type_class,
func.count(Alert.id).label('count')
).where(
and_(
Alert.tenant_id == tenant_id,
Alert.status == AlertStatus.ACTIVE
)
).group_by(Alert.type_class)
active_by_type_result = await session.execute(active_by_type_query)
active_by_type = {row.type_class: row.count for row in active_by_type_result}
# Get period comparison for trends
from app.repositories.analytics_repository import AlertAnalyticsRepository
analytics_repo = AlertAnalyticsRepository(session)
period_comparison = await analytics_repo.get_period_comparison(
tenant_id=tenant_id,
current_days=days,
previous_days=days
)
return {
"period_days": days,
"total_alerts": total_alerts,
"active_alerts": len(active_alerts),
"ai_handling_rate": round(ai_handling_percentage, 1),
"prevented_issues_count": prevented_count,
"estimated_savings_eur": round(estimated_savings, 2),
"total_financial_impact_at_risk_eur": round(total_financial_impact, 2),
"priority_distribution": {
"critical": priority_dist.get(PriorityLevel.CRITICAL, 0),
"important": priority_dist.get(PriorityLevel.IMPORTANT, 0),
"standard": priority_dist.get(PriorityLevel.STANDARD, 0),
"info": priority_dist.get(PriorityLevel.INFO, 0)
},
"type_class_distribution": {
"action_needed": type_class_dist.get(AlertTypeClass.ACTION_NEEDED, 0),
"prevented_issue": type_class_dist.get(AlertTypeClass.PREVENTED_ISSUE, 0),
"trend_warning": type_class_dist.get(AlertTypeClass.TREND_WARNING, 0),
"escalation": type_class_dist.get(AlertTypeClass.ESCALATION, 0),
"information": type_class_dist.get(AlertTypeClass.INFORMATION, 0)
},
"active_by_type_class": active_by_type,
"period_comparison": period_comparison
}
except Exception as e:
logger.error("Failed to get dashboard analytics", error=str(e), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail=f"Failed to get dashboard analytics: {str(e)}")
# ============================================================================ # ============================================================================
# Tenant Data Deletion Operations (Internal Service Only) # Tenant Data Deletion Operations (Internal Service Only)
# ============================================================================ # ============================================================================

View File

@@ -0,0 +1,305 @@
"""
Internal Demo Cloning API for Alert Processor Service
Service-to-service endpoint for cloning alert data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
import os
from app.repositories.alerts_repository import AlertsRepository
from app.models.events import Alert, AlertStatus, AlertTypeClass
from app.config import AlertProcessorConfig
import sys
from pathlib import Path
# Add shared utilities to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
from shared.database.base import create_database_manager
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Internal API key for service-to-service auth
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
# Database manager for this module
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor-internal-demo")
# Dependency to get database session
async def get_db():
"""Get database session for internal demo operations"""
async with db_manager.get_session() as session:
yield session
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""
Clone alert service data for a virtual demo tenant
Clones:
- Action-needed alerts (PO approvals, delivery tracking, low stock warnings, production delays)
- Prevented-issue alerts (AI interventions with financial impact)
- Historical trend data over past 7 days
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
session_created_at: Session creation timestamp for date adjustment
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting alert data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"alerts": 0,
"action_needed": 0,
"prevented_issues": 0,
"historical_alerts": 0
}
# Clone Alerts
result = await db.execute(
select(Alert).where(Alert.tenant_id == base_uuid)
)
base_alerts = result.scalars().all()
logger.info(
"Found alerts to clone",
count=len(base_alerts),
base_tenant=str(base_uuid)
)
for alert in base_alerts:
# Adjust dates relative to session creation time
adjusted_created_at = adjust_date_for_demo(
alert.created_at, session_time, BASE_REFERENCE_DATE
) if alert.created_at else session_time
adjusted_updated_at = adjust_date_for_demo(
alert.updated_at, session_time, BASE_REFERENCE_DATE
) if alert.updated_at else session_time
adjusted_resolved_at = adjust_date_for_demo(
alert.resolved_at, session_time, BASE_REFERENCE_DATE
) if alert.resolved_at else None
adjusted_action_created_at = adjust_date_for_demo(
alert.action_created_at, session_time, BASE_REFERENCE_DATE
) if alert.action_created_at else None
adjusted_scheduled_send_time = adjust_date_for_demo(
alert.scheduled_send_time, session_time, BASE_REFERENCE_DATE
) if alert.scheduled_send_time else None
# Update urgency context with adjusted dates if present
urgency_context = alert.urgency_context.copy() if alert.urgency_context else {}
if urgency_context.get("expected_delivery"):
try:
original_delivery = datetime.fromisoformat(urgency_context["expected_delivery"].replace('Z', '+00:00'))
adjusted_delivery = adjust_date_for_demo(original_delivery, session_time, BASE_REFERENCE_DATE)
urgency_context["expected_delivery"] = adjusted_delivery.isoformat() if adjusted_delivery else None
except:
pass # Keep original if parsing fails
new_alert = Alert(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
item_type=alert.item_type,
alert_type=alert.alert_type,
service=alert.service,
title=alert.title,
message=alert.message,
status=alert.status,
priority_score=alert.priority_score,
priority_level=alert.priority_level,
type_class=alert.type_class,
orchestrator_context=alert.orchestrator_context,
business_impact=alert.business_impact,
urgency_context=urgency_context,
user_agency=alert.user_agency,
trend_context=alert.trend_context,
smart_actions=alert.smart_actions,
ai_reasoning_summary=alert.ai_reasoning_summary,
confidence_score=alert.confidence_score,
timing_decision=alert.timing_decision,
scheduled_send_time=adjusted_scheduled_send_time,
placement=alert.placement,
action_created_at=adjusted_action_created_at,
superseded_by_action_id=None, # Don't clone superseded relationships
hidden_from_ui=alert.hidden_from_ui,
alert_metadata=alert.alert_metadata,
created_at=adjusted_created_at,
updated_at=adjusted_updated_at,
resolved_at=adjusted_resolved_at
)
db.add(new_alert)
stats["alerts"] += 1
# Track by type_class
if alert.type_class == "action_needed":
stats["action_needed"] += 1
elif alert.type_class == "prevented_issue":
stats["prevented_issues"] += 1
# Track historical (older than 1 day)
if adjusted_created_at < session_time - timedelta(days=1):
stats["historical_alerts"] += 1
# Commit cloned data
await db.commit()
total_records = stats["alerts"]
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Alert data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "alert_processor",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone alert data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "alert_processor",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "alert_processor",
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""Delete all alert data for a virtual demo tenant"""
logger.info("Deleting alert data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records
alert_count = await db.scalar(
select(func.count(Alert.id)).where(Alert.tenant_id == virtual_uuid)
)
# Delete alerts
await db.execute(delete(Alert).where(Alert.tenant_id == virtual_uuid))
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Alert data deleted successfully",
virtual_tenant_id=virtual_tenant_id,
duration_ms=duration_ms
)
return {
"service": "alert_processor",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"alerts": alert_count,
"total": alert_count
},
"duration_ms": duration_ms
}
except Exception as e:
logger.error("Failed to delete alert data", error=str(e), exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
import structlog import structlog
from app.config import AlertProcessorConfig from app.config import AlertProcessorConfig
from app.api import analytics_router, alerts_router from app.api import analytics_router, alerts_router, internal_demo_router
from shared.database.base import create_database_manager from shared.database.base import create_database_manager
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -32,6 +32,7 @@ app.add_middleware(
# Include routers # Include routers
app.include_router(analytics_router, tags=["analytics"]) app.include_router(analytics_router, tags=["analytics"])
app.include_router(alerts_router, tags=["alerts"]) app.include_router(alerts_router, tags=["alerts"])
app.include_router(internal_demo_router, tags=["internal"])
# Initialize database # Initialize database
config = AlertProcessorConfig() config = AlertProcessorConfig()
@@ -45,7 +46,7 @@ async def startup():
# Create tables # Create tables
try: try:
from app.models.alerts import Base from shared.database.base import Base
await db_manager.create_tables(Base.metadata) await db_manager.create_tables(Base.metadata)
logger.info("Database tables ensured") logger.info("Database tables ensured")
except Exception as e: except Exception as e:

View File

@@ -58,3 +58,60 @@ class AlertProcessorConfig(BaseServiceSettings):
@property @property
def low_channels(self) -> List[str]: def low_channels(self) -> List[str]:
return ["dashboard"] return ["dashboard"]
# ============================================================
# ENRICHMENT CONFIGURATION (NEW)
# ============================================================
# Priority scoring weights
BUSINESS_IMPACT_WEIGHT: float = float(os.getenv("BUSINESS_IMPACT_WEIGHT", "0.4"))
URGENCY_WEIGHT: float = float(os.getenv("URGENCY_WEIGHT", "0.3"))
USER_AGENCY_WEIGHT: float = float(os.getenv("USER_AGENCY_WEIGHT", "0.2"))
CONFIDENCE_WEIGHT: float = float(os.getenv("CONFIDENCE_WEIGHT", "0.1"))
# Priority thresholds
CRITICAL_THRESHOLD: int = int(os.getenv("CRITICAL_THRESHOLD", "90"))
IMPORTANT_THRESHOLD: int = int(os.getenv("IMPORTANT_THRESHOLD", "70"))
STANDARD_THRESHOLD: int = int(os.getenv("STANDARD_THRESHOLD", "50"))
# Timing intelligence
TIMING_INTELLIGENCE_ENABLED: bool = os.getenv("TIMING_INTELLIGENCE_ENABLED", "true").lower() == "true"
BATCH_LOW_PRIORITY_ALERTS: bool = os.getenv("BATCH_LOW_PRIORITY_ALERTS", "true").lower() == "true"
BUSINESS_HOURS_START: int = int(os.getenv("BUSINESS_HOURS_START", "6"))
BUSINESS_HOURS_END: int = int(os.getenv("BUSINESS_HOURS_END", "22"))
PEAK_HOURS_START: int = int(os.getenv("PEAK_HOURS_START", "7"))
PEAK_HOURS_END: int = int(os.getenv("PEAK_HOURS_END", "11"))
PEAK_HOURS_EVENING_START: int = int(os.getenv("PEAK_HOURS_EVENING_START", "17"))
PEAK_HOURS_EVENING_END: int = int(os.getenv("PEAK_HOURS_EVENING_END", "19"))
# Grouping
GROUPING_TIME_WINDOW_MINUTES: int = int(os.getenv("GROUPING_TIME_WINDOW_MINUTES", "15"))
MAX_ALERTS_PER_GROUP: int = int(os.getenv("MAX_ALERTS_PER_GROUP", "5"))
# Email digest
EMAIL_DIGEST_ENABLED: bool = os.getenv("EMAIL_DIGEST_ENABLED", "true").lower() == "true"
DIGEST_SEND_TIME: str = os.getenv("DIGEST_SEND_TIME", "18:00")
DIGEST_SEND_TIME_HOUR: int = int(os.getenv("DIGEST_SEND_TIME", "18:00").split(":")[0])
DIGEST_MIN_ALERTS: int = int(os.getenv("DIGEST_MIN_ALERTS", "5"))
# Alert grouping
ALERT_GROUPING_ENABLED: bool = os.getenv("ALERT_GROUPING_ENABLED", "true").lower() == "true"
MIN_ALERTS_FOR_GROUPING: int = int(os.getenv("MIN_ALERTS_FOR_GROUPING", "3"))
# Trend detection
TREND_DETECTION_ENABLED: bool = os.getenv("TREND_DETECTION_ENABLED", "true").lower() == "true"
TREND_LOOKBACK_DAYS: int = int(os.getenv("TREND_LOOKBACK_DAYS", "7"))
TREND_SIGNIFICANCE_THRESHOLD: float = float(os.getenv("TREND_SIGNIFICANCE_THRESHOLD", "0.15"))
# Context enrichment
ENRICHMENT_TIMEOUT_SECONDS: int = int(os.getenv("ENRICHMENT_TIMEOUT_SECONDS", "10"))
ORCHESTRATOR_CONTEXT_CACHE_TTL: int = int(os.getenv("ORCHESTRATOR_CONTEXT_CACHE_TTL", "300"))
# Peak hours (aliases for enrichment services)
EVENING_PEAK_START: int = int(os.getenv("PEAK_HOURS_EVENING_START", "17"))
EVENING_PEAK_END: int = int(os.getenv("PEAK_HOURS_EVENING_END", "19"))
# Service URLs for enrichment
ORCHESTRATOR_SERVICE_URL: str = os.getenv("ORCHESTRATOR_SERVICE_URL", "http://orchestrator-service:8000")
INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
PRODUCTION_SERVICE_URL: str = os.getenv("PRODUCTION_SERVICE_URL", "http://production-service:8000")

View File

@@ -0,0 +1,56 @@
"""
FastAPI dependencies for alert processor service
"""
from fastapi import Header, HTTPException, status
from typing import Optional
async def get_current_user(
authorization: Optional[str] = Header(None)
) -> dict:
"""
Extract and validate user from JWT token in Authorization header.
In production, this should verify the JWT token against auth service.
For now, we accept any Authorization header as valid.
Args:
authorization: Bearer token from Authorization header
Returns:
dict: User information extracted from token
Raises:
HTTPException: If no authorization header provided
"""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
# In production, verify JWT and extract user info
# For now, return a basic user dict
return {
"user_id": "system",
"tenant_id": None, # Will be extracted from path parameter
"authenticated": True
}
async def get_optional_user(
authorization: Optional[str] = Header(None)
) -> Optional[dict]:
"""
Optional authentication dependency.
Returns user if authenticated, None otherwise.
"""
if not authorization:
return None
try:
return await get_current_user(authorization)
except HTTPException:
return None

View File

@@ -0,0 +1,12 @@
"""
Scheduled Jobs Package
Contains background jobs for the alert processor service.
"""
from .priority_recalculation import PriorityRecalculationJob, run_priority_recalculation_job
__all__ = [
"PriorityRecalculationJob",
"run_priority_recalculation_job",
]

View File

@@ -0,0 +1,44 @@
"""
Main entry point for alert processor jobs when run as modules.
This file makes the jobs package executable as a module:
`python -m app.jobs.priority_recalculation`
"""
import asyncio
import sys
import os
from pathlib import Path
# Add the app directory to Python path
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "shared"))
from app.jobs.priority_recalculation import run_priority_recalculation_job
from app.config import AlertProcessorConfig
from shared.database.base import create_database_manager
from app.core.cache import get_redis_client
async def main():
"""Main entry point for the priority recalculation job."""
# Initialize services
config = AlertProcessorConfig()
db_manager = create_database_manager(config.DATABASE_URL, "alert-processor")
redis_client = await get_redis_client()
try:
# Run the priority recalculation job
results = await run_priority_recalculation_job(
config=config,
db_manager=db_manager,
redis_client=redis_client
)
print(f"Priority recalculation completed: {results}")
except Exception as e:
print(f"Error running priority recalculation job: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,337 @@
"""
Priority Recalculation Job
Scheduled job that recalculates priority scores for active alerts,
applying time-based escalation boosts.
Runs hourly to ensure stale actions get escalated appropriately.
"""
import structlog
from datetime import datetime, timedelta, timezone
from typing import Dict, List
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.events import Alert, AlertStatus
from app.services.enrichment.priority_scoring import PriorityScoringService
from shared.schemas.alert_types import UrgencyContext
logger = structlog.get_logger()
class PriorityRecalculationJob:
"""Recalculates alert priorities with time-based escalation"""
def __init__(self, config, db_manager, redis_client):
self.config = config
self.db_manager = db_manager
self.redis = redis_client
self.priority_service = PriorityScoringService(config)
async def run(self, tenant_id: UUID = None) -> Dict[str, int]:
"""
Recalculate priorities for all active action-needed alerts.
Args:
tenant_id: Optional tenant filter. If None, runs for all tenants.
Returns:
Dict with counts: {
'processed': int,
'escalated': int,
'errors': int
}
"""
logger.info("Starting priority recalculation job", tenant_id=str(tenant_id) if tenant_id else "all")
counts = {
'processed': 0,
'escalated': 0,
'errors': 0
}
try:
# Process alerts in batches to avoid memory issues and timeouts
batch_size = 50 # Process 50 alerts at a time to prevent timeouts
# Get tenant IDs to process
tenant_ids = [tenant_id] if tenant_id else await self._get_tenant_ids()
for current_tenant_id in tenant_ids:
offset = 0
while True:
async with self.db_manager.get_session() as session:
# Get a batch of active alerts
alerts_batch = await self._get_active_alerts_batch(session, current_tenant_id, offset, batch_size)
if not alerts_batch:
break # No more alerts to process
logger.info(f"Processing batch of {len(alerts_batch)} alerts for tenant {current_tenant_id}, offset {offset}")
for alert in alerts_batch:
try:
result = await self._recalculate_alert_priority(session, alert)
counts['processed'] += 1
if result['escalated']:
counts['escalated'] += 1
except Exception as e:
logger.error(
"Error recalculating alert priority",
alert_id=str(alert.id),
error=str(e)
)
counts['errors'] += 1
# Commit this batch
await session.commit()
# Update offset for next batch
offset += batch_size
# Log progress periodically
if offset % (batch_size * 10) == 0: # Every 10 batches
logger.info(
"Priority recalculation progress update",
tenant_id=str(current_tenant_id),
processed=counts['processed'],
escalated=counts['escalated'],
errors=counts['errors']
)
logger.info(
"Tenant priority recalculation completed",
tenant_id=str(current_tenant_id),
processed=counts['processed'],
escalated=counts['escalated'],
errors=counts['errors']
)
logger.info(
"Priority recalculation completed for all tenants",
**counts
)
except Exception as e:
logger.error(
"Priority recalculation job failed",
error=str(e)
)
counts['errors'] += 1
return counts
async def _get_active_alerts(
self,
session: AsyncSession,
tenant_id: UUID = None
) -> List[Alert]:
"""
Get all active alerts that need priority recalculation.
Filters:
- Status: active
- Type class: action_needed (only these can escalate)
- Has action_created_at set
"""
stmt = select(Alert).where(
Alert.status == AlertStatus.ACTIVE,
Alert.type_class == 'action_needed',
Alert.action_created_at.isnot(None),
Alert.hidden_from_ui == False
)
if tenant_id:
stmt = stmt.where(Alert.tenant_id == tenant_id)
# Order by oldest first (most likely to need escalation)
stmt = stmt.order_by(Alert.action_created_at.asc())
result = await session.execute(stmt)
return result.scalars().all()
async def _get_tenant_ids(self) -> List[UUID]:
"""
Get all unique tenant IDs that have active alerts that need recalculation.
"""
async with self.db_manager.get_session() as session:
# Get unique tenant IDs with active alerts
stmt = select(Alert.tenant_id).distinct().where(
Alert.status == AlertStatus.ACTIVE,
Alert.type_class == 'action_needed',
Alert.action_created_at.isnot(None),
Alert.hidden_from_ui == False
)
result = await session.execute(stmt)
tenant_ids = result.scalars().all()
return tenant_ids
async def _get_active_alerts_batch(
self,
session: AsyncSession,
tenant_id: UUID,
offset: int,
limit: int
) -> List[Alert]:
"""
Get a batch of active alerts that need priority recalculation.
Filters:
- Status: active
- Type class: action_needed (only these can escalate)
- Has action_created_at set
"""
stmt = select(Alert).where(
Alert.status == AlertStatus.ACTIVE,
Alert.type_class == 'action_needed',
Alert.action_created_at.isnot(None),
Alert.hidden_from_ui == False
)
if tenant_id:
stmt = stmt.where(Alert.tenant_id == tenant_id)
# Order by oldest first (most likely to need escalation)
stmt = stmt.order_by(Alert.action_created_at.asc())
# Apply offset and limit for batching
stmt = stmt.offset(offset).limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
async def _recalculate_alert_priority(
self,
session: AsyncSession,
alert: Alert
) -> Dict[str, any]:
"""
Recalculate priority for a single alert with escalation boost.
Returns:
Dict with 'old_score', 'new_score', 'escalated' (bool)
"""
old_score = alert.priority_score
# Build urgency context from alert metadata
urgency_context = None
if alert.urgency_context:
urgency_context = UrgencyContext(**alert.urgency_context)
# Calculate escalation boost
boost = self.priority_service.calculate_escalation_boost(
action_created_at=alert.action_created_at,
urgency_context=urgency_context,
current_priority=old_score
)
# Apply boost
new_score = min(100, old_score + boost)
# Update if score changed
if new_score != old_score:
# Update priority score and level
new_level = self.priority_service.get_priority_level(new_score)
alert.priority_score = new_score
alert.priority_level = new_level
alert.updated_at = datetime.now(timezone.utc)
# Add escalation metadata
if not alert.alert_metadata:
alert.alert_metadata = {}
alert.alert_metadata['escalation'] = {
'original_score': old_score,
'boost_applied': boost,
'escalated_at': datetime.now(timezone.utc).isoformat(),
'reason': 'time_based_escalation'
}
# Invalidate cache
cache_key = f"alert:{alert.tenant_id}:{alert.id}"
await self.redis.delete(cache_key)
logger.info(
"Alert priority escalated",
alert_id=str(alert.id),
old_score=old_score,
new_score=new_score,
boost=boost,
old_level=alert.priority_level if old_score == new_score else self.priority_service.get_priority_level(old_score),
new_level=new_level
)
return {
'old_score': old_score,
'new_score': new_score,
'escalated': True
}
return {
'old_score': old_score,
'new_score': new_score,
'escalated': False
}
async def run_for_all_tenants(self) -> Dict[str, Dict[str, int]]:
"""
Run recalculation for all tenants.
Returns:
Dict mapping tenant_id to counts
"""
logger.info("Running priority recalculation for all tenants")
all_results = {}
try:
# Get unique tenant IDs with active alerts using the new efficient method
tenant_ids = await self._get_tenant_ids()
logger.info(f"Found {len(tenant_ids)} tenants with active alerts")
for tenant_id in tenant_ids:
try:
counts = await self.run(tenant_id)
all_results[str(tenant_id)] = counts
except Exception as e:
logger.error(
"Error processing tenant",
tenant_id=str(tenant_id),
error=str(e)
)
total_processed = sum(r['processed'] for r in all_results.values())
total_escalated = sum(r['escalated'] for r in all_results.values())
total_errors = sum(r['errors'] for r in all_results.values())
logger.info(
"All tenants processed",
tenants=len(all_results),
total_processed=total_processed,
total_escalated=total_escalated,
total_errors=total_errors
)
except Exception as e:
logger.error(
"Failed to run for all tenants",
error=str(e)
)
return all_results
async def run_priority_recalculation_job(config, db_manager, redis_client):
"""
Main entry point for scheduled job.
This is called by the scheduler (cron/celery/etc).
"""
job = PriorityRecalculationJob(config, db_manager, redis_client)
return await job.run_for_all_tenants()

View File

@@ -19,14 +19,33 @@ from shared.database.base import create_database_manager
from shared.clients.base_service_client import BaseServiceClient from shared.clients.base_service_client import BaseServiceClient
from shared.config.rabbitmq_config import RABBITMQ_CONFIG from shared.config.rabbitmq_config import RABBITMQ_CONFIG
# Import enrichment services
from app.services.enrichment import (
PriorityScoringService,
ContextEnrichmentService,
TimingIntelligenceService,
OrchestratorClient
)
from shared.schemas.alert_types import RawAlert
# Setup logging # Setup logging
import logging
# Configure Python's standard logging first (required for structlog.stdlib.LoggerFactory)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=logging.INFO,
)
# Configure structlog to use the standard logging backend
structlog.configure( structlog.configure(
processors=[ processors=[
structlog.stdlib.filter_by_level, structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name, structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(), structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="ISO"), structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(), structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info, structlog.processors.format_exc_info,
structlog.processors.JSONRenderer() structlog.processors.JSONRenderer()
@@ -82,11 +101,18 @@ class AlertProcessorService:
self.channel = None self.channel = None
self.running = False self.running = False
# Initialize enrichment services (context_enrichment initialized after Redis connection)
self.orchestrator_client = OrchestratorClient(config.ORCHESTRATOR_SERVICE_URL)
self.context_enrichment = None # Initialized in start() after Redis connection
self.priority_scoring = PriorityScoringService(config)
self.timing_intelligence = TimingIntelligenceService(config)
# Metrics # Metrics
self.items_processed = 0 self.items_processed = 0
self.items_stored = 0 self.items_stored = 0
self.notifications_sent = 0 self.notifications_sent = 0
self.errors_count = 0 self.errors_count = 0
self.enrichments_count = 0
async def start(self): async def start(self):
"""Start the alert processor service""" """Start the alert processor service"""
@@ -98,6 +124,10 @@ class AlertProcessorService:
self.redis = await get_redis_client() self.redis = await get_redis_client()
logger.info("Connected to Redis") logger.info("Connected to Redis")
# Initialize context enrichment service now that Redis is available
self.context_enrichment = ContextEnrichmentService(self.config, self.db_manager, self.redis)
logger.info("Initialized context enrichment service")
# Connect to RabbitMQ # Connect to RabbitMQ
await self._setup_rabbitmq() await self._setup_rabbitmq()
@@ -156,34 +186,38 @@ class AlertProcessorService:
logger.info("Processing item", logger.info("Processing item",
item_type=item.get('item_type'), item_type=item.get('item_type'),
alert_type=item.get('type'), alert_type=item.get('type'),
severity=item.get('severity'), priority_level=item.get('priority_level', 'standard'),
tenant_id=item.get('tenant_id')) tenant_id=item.get('tenant_id'))
# Store in database # ENRICH ALERT BEFORE STORAGE
stored_item = await self.store_item(item) enriched_item = await self.enrich_alert(item)
self.enrichments_count += 1
# Store enriched alert in database
stored_item = await self.store_enriched_item(enriched_item)
self.items_stored += 1 self.items_stored += 1
# Determine delivery channels based on severity and type # Determine delivery channels based on priority score (not severity)
channels = self.get_channels_by_severity_and_type( channels = self.get_channels_by_priority(enriched_item['priority_score'])
item['severity'],
item['item_type']
)
# Send via notification service if channels are specified # Send via notification service if channels are specified
if channels: if channels:
notification_result = await self.notification_client.send_notification( notification_result = await self.notification_client.send_notification(
tenant_id=item['tenant_id'], tenant_id=enriched_item['tenant_id'],
notification={ notification={
'type': item['item_type'], # 'alert' or 'recommendation' 'type': enriched_item['item_type'],
'id': item['id'], 'id': enriched_item['id'],
'title': item['title'], 'title': enriched_item['title'],
'message': item['message'], 'message': enriched_item['message'],
'severity': item['severity'], 'priority_score': enriched_item['priority_score'],
'metadata': item.get('metadata', {}), 'priority_level': enriched_item['priority_level'],
'actions': item.get('actions', []), 'type_class': enriched_item['type_class'],
'email': item.get('email'), 'metadata': enriched_item.get('metadata', {}),
'phone': item.get('phone'), 'actions': enriched_item.get('smart_actions', []),
'user_id': item.get('user_id') 'ai_reasoning_summary': enriched_item.get('ai_reasoning_summary'),
'email': enriched_item.get('email'),
'phone': enriched_item.get('phone'),
'user_id': enriched_item.get('user_id')
}, },
channels=channels channels=channels
) )
@@ -191,13 +225,15 @@ class AlertProcessorService:
if notification_result and notification_result.get('status') == 'success': if notification_result and notification_result.get('status') == 'success':
self.notifications_sent += 1 self.notifications_sent += 1
# Stream to SSE for real-time dashboard (always) # Stream enriched alert to SSE for real-time dashboard (always)
await self.stream_to_sse(item['tenant_id'], stored_item) await self.stream_to_sse(enriched_item['tenant_id'], stored_item)
self.items_processed += 1 self.items_processed += 1
logger.info("Item processed successfully", logger.info("Item processed successfully",
item_id=item['id'], item_id=enriched_item['id'],
priority_score=enriched_item['priority_score'],
priority_level=enriched_item['priority_level'],
channels=len(channels)) channels=len(channels))
except Exception as e: except Exception as e:
@@ -205,49 +241,143 @@ class AlertProcessorService:
logger.error("Item processing failed", error=str(e)) logger.error("Item processing failed", error=str(e))
raise raise
async def store_item(self, item: dict) -> dict: async def enrich_alert(self, item: dict) -> dict:
"""Store alert or recommendation in database and cache in Redis""" """
from app.models.alerts import Alert, AlertSeverity, AlertStatus Enrich alert with priority scoring, context, and smart actions.
All alerts MUST be enriched - no legacy support.
"""
try:
# Convert dict to RawAlert model
# Map 'type' to 'alert_type' and 'metadata' to 'alert_metadata'
raw_alert = RawAlert(
tenant_id=item['tenant_id'],
alert_type=item.get('type', item.get('alert_type', 'unknown')),
title=item['title'],
message=item['message'],
service=item['service'],
actions=item.get('actions', []),
alert_metadata=item.get('metadata', item.get('alert_metadata', {})),
item_type=item.get('item_type', 'alert')
)
# Enrich with orchestrator context (AI actions, business impact)
enriched = await self.context_enrichment.enrich_alert(raw_alert)
# Convert EnrichedAlert back to dict and merge with original item
# Use mode='json' to properly serialize datetime objects to ISO strings
enriched_dict = enriched.model_dump(mode='json') if hasattr(enriched, 'model_dump') else dict(enriched)
enriched_dict['id'] = item['id'] # Preserve original ID
enriched_dict['item_type'] = item.get('item_type', 'alert') # Preserve item_type
enriched_dict['type'] = enriched_dict.get('alert_type', item.get('type', 'unknown')) # Preserve type field
enriched_dict['timestamp'] = item.get('timestamp', datetime.utcnow().isoformat())
enriched_dict['timing_decision'] = enriched_dict.get('timing_decision', 'send_now') # Default timing decision
# Map 'actions' to 'smart_actions' for database storage
if 'actions' in enriched_dict and 'smart_actions' not in enriched_dict:
enriched_dict['smart_actions'] = enriched_dict['actions']
logger.info("Alert enriched successfully",
alert_id=enriched_dict['id'],
alert_type=enriched_dict.get('alert_type'),
priority_score=enriched_dict['priority_score'],
priority_level=enriched_dict['priority_level'],
type_class=enriched_dict['type_class'],
actions_count=len(enriched_dict.get('actions', [])),
smart_actions_count=len(enriched_dict.get('smart_actions', [])))
return enriched_dict
except Exception as e:
logger.error("Alert enrichment failed, using fallback", error=str(e), alert_id=item.get('id'))
# Fallback: basic enrichment with defaults
return self._create_fallback_enrichment(item)
def _create_fallback_enrichment(self, item: dict) -> dict:
"""
Create fallback enrichment when enrichment services fail.
Ensures all alerts have required enrichment fields.
"""
return {
**item,
'item_type': item.get('item_type', 'alert'), # Ensure item_type is preserved
'type': item.get('type', 'unknown'), # Ensure type field is preserved
'alert_type': item.get('type', item.get('alert_type', 'unknown')), # Ensure alert_type exists
'priority_score': 50,
'priority_level': 'standard',
'type_class': 'action_needed',
'orchestrator_context': None,
'business_impact': None,
'urgency_context': None,
'user_agency': None,
'trend_context': None,
'smart_actions': item.get('actions', []),
'ai_reasoning_summary': None,
'confidence_score': 0.5,
'timing_decision': 'send_now',
'scheduled_send_time': None,
'placement': ['dashboard']
}
async def store_enriched_item(self, enriched_item: dict) -> dict:
"""Store enriched alert in database with all enrichment fields"""
from app.models.events import Alert, AlertStatus
from sqlalchemy import select from sqlalchemy import select
async with self.db_manager.get_session() as session: async with self.db_manager.get_session() as session:
# Create alert instance # Create enriched alert instance
alert = Alert( alert = Alert(
id=item['id'], id=enriched_item['id'],
tenant_id=item['tenant_id'], tenant_id=enriched_item['tenant_id'],
item_type=item['item_type'], # 'alert' or 'recommendation' item_type=enriched_item['item_type'],
alert_type=item['type'], alert_type=enriched_item['type'],
severity=AlertSeverity(item['severity'].lower()), status='active',
status=AlertStatus.ACTIVE, service=enriched_item['service'],
service=item['service'], title=enriched_item['title'],
title=item['title'], message=enriched_item['message'],
message=item['message'],
actions=item.get('actions', []), # Enrichment fields (REQUIRED)
alert_metadata=item.get('metadata', {}), priority_score=enriched_item['priority_score'],
created_at=datetime.fromisoformat(item['timestamp']) if isinstance(item['timestamp'], str) else item['timestamp'] priority_level=enriched_item['priority_level'],
type_class=enriched_item['type_class'],
# Context enrichment (JSONB)
orchestrator_context=enriched_item.get('orchestrator_context'),
business_impact=enriched_item.get('business_impact'),
urgency_context=enriched_item.get('urgency_context'),
user_agency=enriched_item.get('user_agency'),
trend_context=enriched_item.get('trend_context'),
# Smart actions
smart_actions=enriched_item.get('smart_actions', []),
# AI reasoning
ai_reasoning_summary=enriched_item.get('ai_reasoning_summary'),
confidence_score=enriched_item.get('confidence_score', 0.8),
# Timing intelligence
timing_decision=enriched_item.get('timing_decision', 'send_now'),
scheduled_send_time=enriched_item.get('scheduled_send_time'),
# Placement
placement=enriched_item.get('placement', ['dashboard']),
# Metadata (legacy)
alert_metadata=enriched_item.get('metadata', {}),
# Timestamp
created_at=datetime.fromisoformat(enriched_item['timestamp']) if isinstance(enriched_item['timestamp'], str) else enriched_item['timestamp']
) )
session.add(alert) session.add(alert)
await session.commit() await session.commit()
await session.refresh(alert) await session.refresh(alert)
logger.debug("Item stored in database", item_id=item['id']) logger.debug("Enriched item stored in database",
item_id=enriched_item['id'],
priority_score=alert.priority_score,
type_class=alert.type_class)
# Convert to dict for return # Convert to enriched dict for return
alert_dict = { alert_dict = alert.to_dict()
'id': str(alert.id),
'tenant_id': str(alert.tenant_id),
'item_type': alert.item_type,
'alert_type': alert.alert_type,
'severity': alert.severity.value,
'status': alert.status.value,
'service': alert.service,
'title': alert.title,
'message': alert.message,
'actions': alert.actions,
'metadata': alert.alert_metadata,
'created_at': alert.created_at
}
# Cache active alerts in Redis for SSE initial_items # Cache active alerts in Redis for SSE initial_items
await self._cache_active_alerts(str(alert.tenant_id)) await self._cache_active_alerts(str(alert.tenant_id))
@@ -263,7 +393,7 @@ class AlertProcessorService:
Analytics endpoints should query the database directly for historical data. Analytics endpoints should query the database directly for historical data.
""" """
try: try:
from app.models.alerts import Alert, AlertStatus from app.models.events import Alert, AlertStatus
from sqlalchemy import select from sqlalchemy import select
async with self.db_manager.get_session() as session: async with self.db_manager.get_session() as session:
@@ -281,21 +411,10 @@ class AlertProcessorService:
result = await session.execute(query) result = await session.execute(query)
alerts = result.scalars().all() alerts = result.scalars().all()
# Convert to JSON-serializable format # Convert to enriched JSON-serializable format
active_items = [] active_items = []
for alert in alerts: for alert in alerts:
active_items.append({ active_items.append(alert.to_dict())
'id': str(alert.id),
'item_type': alert.item_type,
'type': alert.alert_type,
'severity': alert.severity.value,
'title': alert.title,
'message': alert.message,
'actions': alert.actions or [],
'metadata': alert.alert_metadata or {},
'timestamp': alert.created_at.isoformat() if alert.created_at else datetime.utcnow().isoformat(),
'status': alert.status.value
})
# Cache in Redis with 1 hour TTL # Cache in Redis with 1 hour TTL
cache_key = f"active_alerts:{tenant_id}" cache_key = f"active_alerts:{tenant_id}"
@@ -316,56 +435,50 @@ class AlertProcessorService:
error=str(e)) error=str(e))
async def stream_to_sse(self, tenant_id: str, item: dict): async def stream_to_sse(self, tenant_id: str, item: dict):
"""Publish item to Redis for SSE streaming""" """Publish enriched item to Redis for SSE streaming"""
channel = f"alerts:{tenant_id}" channel = f"alerts:{tenant_id}"
# Prepare message for SSE # Item is already enriched dict from store_enriched_item
# Just ensure timestamp is serializable
sse_message = { sse_message = {
'id': item['id'], **item,
'item_type': item['item_type'], 'timestamp': item['created_at'].isoformat() if hasattr(item['created_at'], 'isoformat') else item['created_at']
'type': item['alert_type'],
'severity': item['severity'],
'title': item['title'],
'message': item['message'],
'actions': json.loads(item['actions']) if isinstance(item['actions'], str) else item['actions'],
'metadata': json.loads(item['metadata']) if isinstance(item['metadata'], str) else item['metadata'],
'timestamp': item['created_at'].isoformat() if hasattr(item['created_at'], 'isoformat') else item['created_at'],
'status': item['status']
} }
# Publish to Redis channel for SSE # Publish to Redis channel for SSE
await self.redis.publish(channel, json.dumps(sse_message)) await self.redis.publish(channel, json.dumps(sse_message))
logger.debug("Item published to SSE", tenant_id=tenant_id, item_id=item['id']) logger.debug("Enriched item published to SSE",
tenant_id=tenant_id,
item_id=item['id'],
priority_score=item.get('priority_score'))
def get_channels_by_severity_and_type(self, severity: str, item_type: str) -> list: def get_channels_by_priority(self, priority_score: int) -> list:
"""Determine notification channels based on severity, type, and time""" """
Determine notification channels based on priority score and timing.
Uses multi-factor priority score (0-100) instead of legacy severity.
"""
current_hour = datetime.now().hour current_hour = datetime.now().hour
channels = ['dashboard'] # Always include dashboard (SSE) channels = ['dashboard'] # Always include dashboard (SSE)
if item_type == 'alert': # Critical priority (90-100): All channels immediately
if severity == 'urgent': if priority_score >= self.config.CRITICAL_THRESHOLD:
# Urgent alerts: All channels immediately channels.extend(['whatsapp', 'email', 'push'])
channels.extend(['whatsapp', 'email', 'push'])
elif severity == 'high':
# High alerts: WhatsApp and email during extended hours
if 6 <= current_hour <= 22:
channels.extend(['whatsapp', 'email'])
else:
channels.append('email') # Email only during night
elif severity == 'medium':
# Medium alerts: Email during business hours
if 7 <= current_hour <= 20:
channels.append('email')
# Low severity: Dashboard only
elif item_type == 'recommendation': # Important priority (70-89): WhatsApp and email during extended hours
# Recommendations: Less urgent, limit channels and respect business hours elif priority_score >= self.config.IMPORTANT_THRESHOLD:
if severity in ['medium', 'high']: if 6 <= current_hour <= 22:
if 8 <= current_hour <= 19: # Business hours for recommendations channels.extend(['whatsapp', 'email'])
channels.append('email') else:
# Low/urgent (rare for recs): Dashboard only channels.append('email') # Email only during night
# Standard priority (50-69): Email during business hours
elif priority_score >= self.config.STANDARD_THRESHOLD:
if 7 <= current_hour <= 20:
channels.append('email')
# Info priority (0-49): Dashboard only
return channels return channels
@@ -392,6 +505,7 @@ class AlertProcessorService:
return { return {
"items_processed": self.items_processed, "items_processed": self.items_processed,
"items_stored": self.items_stored, "items_stored": self.items_stored,
"enrichments_count": self.enrichments_count,
"notifications_sent": self.notifications_sent, "notifications_sent": self.notifications_sent,
"errors_count": self.errors_count, "errors_count": self.errors_count,
"running": self.running "running": self.running
@@ -399,8 +513,11 @@ class AlertProcessorService:
async def main(): async def main():
"""Main entry point""" """Main entry point"""
print("STARTUP: Inside main() function", file=sys.stderr, flush=True)
config = AlertProcessorConfig() config = AlertProcessorConfig()
print("STARTUP: Config created", file=sys.stderr, flush=True)
service = AlertProcessorService(config) service = AlertProcessorService(config)
print("STARTUP: Service created", file=sys.stderr, flush=True)
# Setup signal handlers for graceful shutdown # Setup signal handlers for graceful shutdown
async def shutdown(): async def shutdown():
@@ -414,7 +531,9 @@ async def main():
try: try:
# Start the service # Start the service
print("STARTUP: About to start service", file=sys.stderr, flush=True)
await service.start() await service.start()
print("STARTUP: Service started successfully", file=sys.stderr, flush=True)
# Keep running # Keep running
while service.running: while service.running:
@@ -428,4 +547,13 @@ async def main():
await service.stop() await service.stop()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) print("STARTUP: Entering main block", file=sys.stderr, flush=True)
try:
print("STARTUP: About to run main()", file=sys.stderr, flush=True)
asyncio.run(main())
print("STARTUP: main() completed", file=sys.stderr, flush=True)
except Exception as e:
print(f"STARTUP: FATAL ERROR: {e}", file=sys.stderr, flush=True)
import traceback
traceback.print_exc(file=sys.stderr)
raise

View File

@@ -12,12 +12,31 @@ from shared.database.base import Base
AuditLog = create_audit_log_model(Base) AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata # Import all models to register them with the Base metadata
from .alerts import Alert, AlertStatus, AlertSeverity from .events import (
Alert,
Notification,
Recommendation,
EventInteraction,
AlertStatus,
PriorityLevel,
AlertTypeClass,
NotificationType,
RecommendationType,
)
# List all models for easier access # List all models for easier access
__all__ = [ __all__ = [
# New event models
"Alert", "Alert",
"Notification",
"Recommendation",
"EventInteraction",
# Enums
"AlertStatus", "AlertStatus",
"AlertSeverity", "PriorityLevel",
"AlertTypeClass",
"NotificationType",
"RecommendationType",
# System
"AuditLog", "AuditLog",
] ]

View File

@@ -1,90 +0,0 @@
# services/alert_processor/app/models/alerts.py
"""
Alert models for the alert processor service
"""
from sqlalchemy import Column, String, Text, DateTime, JSON, Enum, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime, timezone
import uuid
import enum
from shared.database.base import Base
def utc_now():
"""Return current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
class AlertStatus(enum.Enum):
"""Alert status values"""
ACTIVE = "active"
RESOLVED = "resolved"
ACKNOWLEDGED = "acknowledged"
IGNORED = "ignored"
class AlertSeverity(enum.Enum):
"""Alert severity levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class InteractionType(enum.Enum):
"""Alert interaction types"""
ACKNOWLEDGED = "acknowledged"
RESOLVED = "resolved"
SNOOZED = "snoozed"
DISMISSED = "dismissed"
class Alert(Base):
"""Alert records for the alert processor service"""
__tablename__ = "alerts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Alert classification
item_type = Column(String(50), nullable=False) # 'alert' or 'recommendation'
alert_type = Column(String(100), nullable=False) # e.g., 'overstock_warning'
severity = Column(Enum(AlertSeverity, values_callable=lambda obj: [e.value for e in obj]), nullable=False, index=True)
status = Column(Enum(AlertStatus, values_callable=lambda obj: [e.value for e in obj]), default=AlertStatus.ACTIVE, index=True)
# Source and content
service = Column(String(100), nullable=False) # originating service
title = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
# Actions and metadata
actions = Column(JSON, nullable=True) # List of available actions
alert_metadata = Column(JSON, nullable=True) # Additional alert-specific data
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
resolved_at = Column(DateTime(timezone=True), nullable=True)
class AlertInteraction(Base):
"""Alert interaction tracking for analytics"""
__tablename__ = "alert_interactions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
alert_id = Column(UUID(as_uuid=True), ForeignKey('alerts.id', ondelete='CASCADE'), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Interaction details
interaction_type = Column(String(50), nullable=False, index=True)
interacted_at = Column(DateTime(timezone=True), nullable=False, default=utc_now, index=True)
response_time_seconds = Column(Integer, nullable=True)
# Context
interaction_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), nullable=False, default=utc_now)

View File

@@ -0,0 +1,402 @@
"""
Unified Event Storage Models
This module defines separate storage models for:
- Alerts: Full enrichment, lifecycle tracking
- Notifications: Lightweight, ephemeral (7-day TTL)
- Recommendations: Medium weight, dismissible
Replaces the old single Alert model with semantic clarity.
"""
from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Float, CheckConstraint, Index, Boolean, Enum
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, Optional
import uuid
import enum
from shared.database.base import Base
def utc_now():
"""Return current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
# ============================================================
# ENUMS
# ============================================================
class AlertStatus(enum.Enum):
"""Alert lifecycle status"""
ACTIVE = "active"
RESOLVED = "resolved"
ACKNOWLEDGED = "acknowledged"
IN_PROGRESS = "in_progress"
DISMISSED = "dismissed"
IGNORED = "ignored"
class PriorityLevel(enum.Enum):
"""Priority levels based on multi-factor scoring"""
CRITICAL = "critical" # 90-100
IMPORTANT = "important" # 70-89
STANDARD = "standard" # 50-69
INFO = "info" # 0-49
class AlertTypeClass(enum.Enum):
"""Alert type classification (for alerts only)"""
ACTION_NEEDED = "action_needed" # Requires user action
PREVENTED_ISSUE = "prevented_issue" # AI already handled
TREND_WARNING = "trend_warning" # Pattern detected
ESCALATION = "escalation" # Time-sensitive with countdown
INFORMATION = "information" # FYI only
class NotificationType(enum.Enum):
"""Notification type classification"""
STATE_CHANGE = "state_change"
COMPLETION = "completion"
ARRIVAL = "arrival"
DEPARTURE = "departure"
UPDATE = "update"
SYSTEM_EVENT = "system_event"
class RecommendationType(enum.Enum):
"""Recommendation type classification"""
OPTIMIZATION = "optimization"
COST_REDUCTION = "cost_reduction"
RISK_MITIGATION = "risk_mitigation"
TREND_INSIGHT = "trend_insight"
BEST_PRACTICE = "best_practice"
# ============================================================
# ALERT MODEL (Full Enrichment)
# ============================================================
class Alert(Base):
"""
Alert model with full enrichment capabilities.
Used for EventClass.ALERT only.
Full priority scoring, context enrichment, smart actions, lifecycle tracking.
"""
__tablename__ = "alerts"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
item_type = Column(String(50), nullable=False) # 'alert' or 'recommendation' - from old schema
event_domain = Column(String(50), nullable=True, index=True) # inventory, production, etc. - new field, make nullable for now
alert_type = Column(String(100), nullable=False) # specific type of alert (e.g., 'low_stock', 'supplier_delay') - from old schema
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Alert-specific classification
type_class = Column(
Enum(AlertTypeClass, name='alerttypeclass', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=True
)
# Status
status = Column(
Enum(AlertStatus, name='alertstatus', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
default=AlertStatus.ACTIVE,
nullable=False,
index=True
)
# Priority (multi-factor scored)
priority_score = Column(Integer, nullable=False) # 0-100
priority_level = Column(
Enum(PriorityLevel, name='prioritylevel', create_type=False, native_enum=True, values_callable=lambda x: [e.value for e in x]),
nullable=False,
index=True
)
# Enrichment context (JSONB)
orchestrator_context = Column(JSONB, nullable=True)
business_impact = Column(JSONB, nullable=True)
urgency_context = Column(JSONB, nullable=True)
user_agency = Column(JSONB, nullable=True)
trend_context = Column(JSONB, nullable=True)
# Smart actions
smart_actions = Column(JSONB, nullable=False)
# AI reasoning
ai_reasoning_summary = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=False, default=0.8)
# Timing intelligence
timing_decision = Column(String(50), nullable=False, default='send_now')
scheduled_send_time = Column(DateTime(timezone=True), nullable=True)
# Placement hints
placement = Column(JSONB, nullable=False)
# Escalation & chaining
action_created_at = Column(DateTime(timezone=True), nullable=True, index=True)
superseded_by_action_id = Column(UUID(as_uuid=True), nullable=True, index=True)
hidden_from_ui = Column(Boolean, default=False, nullable=False, index=True)
# Metadata
alert_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
resolved_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_alerts_tenant_status', 'tenant_id', 'status'),
Index('idx_alerts_priority_score', 'tenant_id', 'priority_score', 'created_at'),
Index('idx_alerts_type_class', 'tenant_id', 'type_class', 'status'),
Index('idx_alerts_domain', 'tenant_id', 'event_domain', 'status'),
Index('idx_alerts_timing', 'timing_decision', 'scheduled_send_time'),
CheckConstraint('priority_score >= 0 AND priority_score <= 100', name='chk_alert_priority_range'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'alert',
'event_domain': self.event_domain,
'event_type': self.alert_type,
'alert_type': self.alert_type, # Frontend expects this field name
'service': self.service,
'title': self.title,
'message': self.message,
'type_class': self.type_class.value if isinstance(self.type_class, AlertTypeClass) else self.type_class,
'status': self.status.value if isinstance(self.status, AlertStatus) else self.status,
'priority_level': self.priority_level.value if isinstance(self.priority_level, PriorityLevel) else self.priority_level,
'priority_score': self.priority_score,
'orchestrator_context': self.orchestrator_context,
'business_impact': self.business_impact,
'urgency_context': self.urgency_context,
'user_agency': self.user_agency,
'trend_context': self.trend_context,
'actions': self.smart_actions,
'ai_reasoning_summary': self.ai_reasoning_summary,
'confidence_score': self.confidence_score,
'timing_decision': self.timing_decision,
'scheduled_send_time': self.scheduled_send_time.isoformat() if self.scheduled_send_time else None,
'placement': self.placement,
'action_created_at': self.action_created_at.isoformat() if self.action_created_at else None,
'superseded_by_action_id': str(self.superseded_by_action_id) if self.superseded_by_action_id else None,
'hidden_from_ui': self.hidden_from_ui,
'alert_metadata': self.alert_metadata, # Frontend expects alert_metadata
'metadata': self.alert_metadata, # Keep legacy field for backwards compat
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None,
}
# ============================================================
# NOTIFICATION MODEL (Lightweight, Ephemeral)
# ============================================================
class Notification(Base):
"""
Notification model for informational state changes.
Used for EventClass.NOTIFICATION only.
Lightweight schema, no priority scoring, no lifecycle, 7-day TTL.
"""
__tablename__ = "notifications"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
event_domain = Column(String(50), nullable=False, index=True)
event_type = Column(String(100), nullable=False)
notification_type = Column(String(50), nullable=False) # NotificationType
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Entity context (optional)
entity_type = Column(String(100), nullable=True) # 'batch', 'delivery', 'po', etc.
entity_id = Column(String(100), nullable=True, index=True)
old_state = Column(String(100), nullable=True)
new_state = Column(String(100), nullable=True)
# Display metadata
notification_metadata = Column(JSONB, nullable=True)
# Placement hints (lightweight)
placement = Column(JSONB, nullable=False, default=['notification_panel'])
# TTL tracking
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
__table_args__ = (
Index('idx_notifications_tenant_domain', 'tenant_id', 'event_domain', 'created_at'),
Index('idx_notifications_entity', 'tenant_id', 'entity_type', 'entity_id'),
Index('idx_notifications_expiry', 'expires_at'),
)
def __init__(self, **kwargs):
"""Set default expiry to 7 days from now"""
if 'expires_at' not in kwargs:
kwargs['expires_at'] = utc_now() + timedelta(days=7)
super().__init__(**kwargs)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'notification',
'event_domain': self.event_domain,
'event_type': self.event_type,
'notification_type': self.notification_type,
'service': self.service,
'title': self.title,
'message': self.message,
'entity_type': self.entity_type,
'entity_id': self.entity_id,
'old_state': self.old_state,
'new_state': self.new_state,
'metadata': self.notification_metadata,
'placement': self.placement,
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
}
# ============================================================
# RECOMMENDATION MODEL (Medium Weight, Dismissible)
# ============================================================
class Recommendation(Base):
"""
Recommendation model for AI-generated suggestions.
Used for EventClass.RECOMMENDATION only.
Medium weight schema, light priority, no orchestrator queries, dismissible.
"""
__tablename__ = "recommendations"
# Primary key
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event classification
event_domain = Column(String(50), nullable=False, index=True)
event_type = Column(String(100), nullable=False)
recommendation_type = Column(String(50), nullable=False) # RecommendationType
service = Column(String(100), nullable=False)
# Content
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
# Light priority (info by default)
priority_level = Column(String(50), nullable=False, default='info')
# Context (lighter than alerts)
estimated_impact = Column(JSONB, nullable=True)
suggested_actions = Column(JSONB, nullable=True)
# AI reasoning
ai_reasoning_summary = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=True)
# Dismissal tracking
dismissed_at = Column(DateTime(timezone=True), nullable=True, index=True)
dismissed_by = Column(UUID(as_uuid=True), nullable=True)
# Metadata
recommendation_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False, index=True)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
__table_args__ = (
Index('idx_recommendations_tenant_domain', 'tenant_id', 'event_domain', 'created_at'),
Index('idx_recommendations_dismissed', 'tenant_id', 'dismissed_at'),
)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/SSE"""
return {
'id': str(self.id),
'tenant_id': str(self.tenant_id),
'event_class': 'recommendation',
'event_domain': self.event_domain,
'event_type': self.event_type,
'recommendation_type': self.recommendation_type,
'service': self.service,
'title': self.title,
'message': self.message,
'priority_level': self.priority_level,
'estimated_impact': self.estimated_impact,
'suggested_actions': self.suggested_actions,
'ai_reasoning_summary': self.ai_reasoning_summary,
'confidence_score': self.confidence_score,
'dismissed_at': self.dismissed_at.isoformat() if self.dismissed_at else None,
'dismissed_by': str(self.dismissed_by) if self.dismissed_by else None,
'metadata': self.recommendation_metadata,
'timestamp': self.created_at.isoformat() if self.created_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
# ============================================================
# INTERACTION TRACKING (Shared across all event types)
# ============================================================
class EventInteraction(Base):
"""Event interaction tracking for analytics"""
__tablename__ = "event_interactions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event reference (polymorphic)
event_id = Column(UUID(as_uuid=True), nullable=False, index=True)
event_class = Column(String(50), nullable=False, index=True) # 'alert', 'notification', 'recommendation'
# User
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Interaction details
interaction_type = Column(String(50), nullable=False, index=True) # acknowledged, resolved, dismissed, clicked, etc.
interacted_at = Column(DateTime(timezone=True), nullable=False, default=utc_now, index=True)
response_time_seconds = Column(Integer, nullable=True)
# Context
interaction_metadata = Column(JSONB, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), nullable=False, default=utc_now)
__table_args__ = (
Index('idx_event_interactions_event', 'event_id', 'event_class'),
Index('idx_event_interactions_user', 'tenant_id', 'user_id', 'interacted_at'),
)

View File

@@ -9,7 +9,7 @@ from typing import List, Dict, Any, Optional
from uuid import UUID from uuid import UUID
import structlog import structlog
from app.models.alerts import Alert, AlertStatus, AlertSeverity from app.models.events import Alert, AlertStatus
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -23,7 +23,7 @@ class AlertsRepository:
async def get_alerts( async def get_alerts(
self, self,
tenant_id: UUID, tenant_id: UUID,
severity: Optional[str] = None, priority_level: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
resolved: Optional[bool] = None, resolved: Optional[bool] = None,
limit: int = 100, limit: int = 100,
@@ -34,7 +34,7 @@ class AlertsRepository:
Args: Args:
tenant_id: Tenant UUID tenant_id: Tenant UUID
severity: Filter by severity (low, medium, high, urgent) priority_level: Filter by priority level (critical, important, standard, info)
status: Filter by status (active, resolved, acknowledged, ignored) status: Filter by status (active, resolved, acknowledged, ignored)
resolved: Filter by resolved status (True = resolved, False = not resolved, None = all) resolved: Filter by resolved status (True = resolved, False = not resolved, None = all)
limit: Maximum number of results limit: Maximum number of results
@@ -47,17 +47,24 @@ class AlertsRepository:
query = select(Alert).where(Alert.tenant_id == tenant_id) query = select(Alert).where(Alert.tenant_id == tenant_id)
# Apply filters # Apply filters
if severity: if priority_level:
query = query.where(Alert.severity == severity) query = query.where(Alert.priority_level == priority_level)
if status: if status:
query = query.where(Alert.status == status) # Convert string status to enum value
try:
status_enum = AlertStatus(status.lower())
query = query.where(Alert.status == status_enum)
except ValueError:
# Invalid status value, log and continue without filtering
logger.warning("Invalid status value provided", status=status)
pass
if resolved is not None: if resolved is not None:
if resolved: if resolved:
query = query.where(Alert.status == AlertStatus.RESOLVED.value) query = query.where(Alert.status == AlertStatus.RESOLVED)
else: else:
query = query.where(Alert.status != AlertStatus.RESOLVED.value) query = query.where(Alert.status != AlertStatus.RESOLVED)
# Order by created_at descending (newest first) # Order by created_at descending (newest first)
query = query.order_by(Alert.created_at.desc()) query = query.order_by(Alert.created_at.desc())
@@ -72,7 +79,7 @@ class AlertsRepository:
"Retrieved alerts", "Retrieved alerts",
tenant_id=str(tenant_id), tenant_id=str(tenant_id),
count=len(alerts), count=len(alerts),
filters={"severity": severity, "status": status, "resolved": resolved} filters={"priority_level": priority_level, "status": status, "resolved": resolved}
) )
return list(alerts) return list(alerts)
@@ -83,32 +90,32 @@ class AlertsRepository:
async def get_alerts_summary(self, tenant_id: UUID) -> Dict[str, Any]: async def get_alerts_summary(self, tenant_id: UUID) -> Dict[str, Any]:
""" """
Get summary of alerts by severity and status Get summary of alerts by priority level and status
Args: Args:
tenant_id: Tenant UUID tenant_id: Tenant UUID
Returns: Returns:
Dict with counts by severity and status Dict with counts by priority level and status
""" """
try: try:
# Count by severity # Count by priority level
severity_query = ( priority_query = (
select( select(
Alert.severity, Alert.priority_level,
func.count(Alert.id).label("count") func.count(Alert.id).label("count")
) )
.where( .where(
and_( and_(
Alert.tenant_id == tenant_id, Alert.tenant_id == tenant_id,
Alert.status != AlertStatus.RESOLVED.value Alert.status != AlertStatus.RESOLVED
) )
) )
.group_by(Alert.severity) .group_by(Alert.priority_level)
) )
severity_result = await self.db.execute(severity_query) priority_result = await self.db.execute(priority_query)
severity_counts = {row[0]: row[1] for row in severity_result.all()} priority_counts = {row[0]: row[1] for row in priority_result.all()}
# Count by status # Count by status
status_query = ( status_query = (
@@ -126,19 +133,23 @@ class AlertsRepository:
# Count active alerts (not resolved) # Count active alerts (not resolved)
active_count = sum( active_count = sum(
count for status, count in status_counts.items() count for status, count in status_counts.items()
if status != AlertStatus.RESOLVED.value if status != AlertStatus.RESOLVED
) )
# Convert enum values to strings for dictionary lookups
status_counts_str = {status.value if hasattr(status, 'value') else status: count
for status, count in status_counts.items()}
# Map to expected field names (dashboard expects "critical") # Map to expected field names (dashboard expects "critical")
summary = { summary = {
"total_count": sum(status_counts.values()), "total_count": sum(status_counts.values()),
"active_count": active_count, "active_count": active_count,
"critical_count": severity_counts.get(AlertSeverity.URGENT.value, 0), # Map URGENT to critical "critical_count": priority_counts.get('critical', 0),
"high_count": severity_counts.get(AlertSeverity.HIGH.value, 0), "high_count": priority_counts.get('important', 0),
"medium_count": severity_counts.get(AlertSeverity.MEDIUM.value, 0), "medium_count": priority_counts.get('standard', 0),
"low_count": severity_counts.get(AlertSeverity.LOW.value, 0), "low_count": priority_counts.get('info', 0),
"resolved_count": status_counts.get(AlertStatus.RESOLVED.value, 0), "resolved_count": status_counts_str.get('resolved', 0),
"acknowledged_count": status_counts.get(AlertStatus.ACKNOWLEDGED.value, 0), "acknowledged_count": status_counts_str.get('acknowledged', 0),
} }
logger.info( logger.info(

View File

@@ -10,7 +10,7 @@ from sqlalchemy import select, func, and_, extract, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import structlog import structlog
from app.models.alerts import Alert, AlertInteraction, AlertSeverity, AlertStatus from app.models.events import Alert, EventInteraction, AlertStatus
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -28,7 +28,7 @@ class AlertAnalyticsRepository:
user_id: UUID, user_id: UUID,
interaction_type: str, interaction_type: str,
metadata: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None
) -> AlertInteraction: ) -> EventInteraction:
"""Create a new alert interaction""" """Create a new alert interaction"""
# Get alert to calculate response time # Get alert to calculate response time
@@ -44,7 +44,7 @@ class AlertAnalyticsRepository:
response_time_seconds = int((now - alert.created_at).total_seconds()) response_time_seconds = int((now - alert.created_at).total_seconds())
# Create interaction # Create interaction
interaction = AlertInteraction( interaction = EventInteraction(
tenant_id=tenant_id, tenant_id=tenant_id,
alert_id=alert_id, alert_id=alert_id,
user_id=user_id, user_id=user_id,
@@ -81,7 +81,7 @@ class AlertAnalyticsRepository:
self, self,
tenant_id: UUID, tenant_id: UUID,
interactions: List[Dict[str, Any]] interactions: List[Dict[str, Any]]
) -> List[AlertInteraction]: ) -> List[EventInteraction]:
"""Create multiple interactions in batch""" """Create multiple interactions in batch"""
created_interactions = [] created_interactions = []
@@ -113,22 +113,26 @@ class AlertAnalyticsRepository:
"""Get alert trends for the last N days""" """Get alert trends for the last N days"""
start_date = datetime.utcnow() - timedelta(days=days) start_date = datetime.utcnow() - timedelta(days=days)
# Query alerts grouped by date and severity # Query alerts grouped by date and priority_level (mapping to severity equivalents)
# Critical priority_level maps to urgent severity
# Important priority_level maps to high severity
# Standard priority_level maps to medium severity
# Info priority_level maps to low severity
query = ( query = (
select( select(
func.date(Alert.created_at).label('date'), func.date(Alert.created_at).label('date'),
func.count(Alert.id).label('total_count'), func.count(Alert.id).label('total_count'),
func.sum( func.sum(
case((Alert.severity == AlertSeverity.URGENT, 1), else_=0) case((Alert.priority_level == 'critical', 1), else_=0)
).label('urgent_count'), ).label('urgent_count'),
func.sum( func.sum(
case((Alert.severity == AlertSeverity.HIGH, 1), else_=0) case((Alert.priority_level == 'important', 1), else_=0)
).label('high_count'), ).label('high_count'),
func.sum( func.sum(
case((Alert.severity == AlertSeverity.MEDIUM, 1), else_=0) case((Alert.priority_level == 'standard', 1), else_=0)
).label('medium_count'), ).label('medium_count'),
func.sum( func.sum(
case((Alert.severity == AlertSeverity.LOW, 1), else_=0) case((Alert.priority_level == 'info', 1), else_=0)
).label('low_count') ).label('low_count')
) )
.where( .where(
@@ -178,13 +182,13 @@ class AlertAnalyticsRepository:
start_date = datetime.utcnow() - timedelta(days=days) start_date = datetime.utcnow() - timedelta(days=days)
query = ( query = (
select(func.avg(AlertInteraction.response_time_seconds)) select(func.avg(EventInteraction.response_time_seconds))
.where( .where(
and_( and_(
AlertInteraction.tenant_id == tenant_id, EventInteraction.tenant_id == tenant_id,
AlertInteraction.interaction_type == 'acknowledged', EventInteraction.interaction_type == 'acknowledged',
AlertInteraction.interacted_at >= start_date, EventInteraction.interacted_at >= start_date,
AlertInteraction.response_time_seconds < 86400 # Less than 24 hours EventInteraction.response_time_seconds < 86400 # Less than 24 hours
) )
) )
) )
@@ -380,3 +384,125 @@ class AlertAnalyticsRepository:
'predictedDailyAverage': predicted_avg, 'predictedDailyAverage': predicted_avg,
'busiestDay': busiest_day 'busiestDay': busiest_day
} }
async def get_period_comparison(
self,
tenant_id: UUID,
current_days: int = 7,
previous_days: int = 7
) -> Dict[str, Any]:
"""
Compare current period metrics with previous period.
Used for week-over-week trend analysis in dashboard cards.
Args:
tenant_id: Tenant ID
current_days: Number of days in current period (default 7)
previous_days: Number of days in previous period (default 7)
Returns:
Dictionary with current/previous metrics and percentage changes
"""
from datetime import datetime, timedelta
now = datetime.utcnow()
current_start = now - timedelta(days=current_days)
previous_start = current_start - timedelta(days=previous_days)
previous_end = current_start
# Current period: AI handling rate (prevented issues / total)
current_total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= current_start,
Alert.created_at <= now
)
)
current_total_result = await self.session.execute(current_total_query)
current_total = current_total_result.scalar() or 0
current_prevented_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= current_start,
Alert.created_at <= now
)
)
current_prevented_result = await self.session.execute(current_prevented_query)
current_prevented = current_prevented_result.scalar() or 0
current_handling_rate = (
(current_prevented / current_total * 100)
if current_total > 0 else 0
)
# Previous period: AI handling rate
previous_total_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.created_at >= previous_start,
Alert.created_at < previous_end
)
)
previous_total_result = await self.session.execute(previous_total_query)
previous_total = previous_total_result.scalar() or 0
previous_prevented_query = select(func.count(Alert.id)).where(
and_(
Alert.tenant_id == tenant_id,
Alert.type_class == 'prevented_issue',
Alert.created_at >= previous_start,
Alert.created_at < previous_end
)
)
previous_prevented_result = await self.session.execute(previous_prevented_query)
previous_prevented = previous_prevented_result.scalar() or 0
previous_handling_rate = (
(previous_prevented / previous_total * 100)
if previous_total > 0 else 0
)
# Calculate percentage change
if previous_handling_rate > 0:
handling_rate_change = round(
((current_handling_rate - previous_handling_rate) / previous_handling_rate) * 100,
1
)
elif current_handling_rate > 0:
handling_rate_change = 100.0 # Went from 0% to something
else:
handling_rate_change = 0.0
# Alert count change
if previous_total > 0:
alert_count_change = round(
((current_total - previous_total) / previous_total) * 100,
1
)
elif current_total > 0:
alert_count_change = 100.0
else:
alert_count_change = 0.0
return {
'current_period': {
'days': current_days,
'total_alerts': current_total,
'prevented_issues': current_prevented,
'handling_rate_percentage': round(current_handling_rate, 1)
},
'previous_period': {
'days': previous_days,
'total_alerts': previous_total,
'prevented_issues': previous_prevented,
'handling_rate_percentage': round(previous_handling_rate, 1)
},
'changes': {
'handling_rate_change_percentage': handling_rate_change,
'alert_count_change_percentage': alert_count_change,
'trend_direction': 'up' if handling_rate_change > 0 else ('down' if handling_rate_change < 0 else 'stable')
}
}

Some files were not shown because too many files have changed in this diff Show More