New alert system and panel de control page
This commit is contained in:
43
Tiltfile
43
Tiltfile
@@ -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
41
frontend/.dockerignore
Normal 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
|
||||||
@@ -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;"]
|
||||||
|
|
||||||
|
|||||||
89
frontend/Dockerfile.kubernetes.debug
Normal file
89
frontend/Dockerfile.kubernetes.debug
Normal 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
12
frontend/nginx-main.conf
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
230
frontend/src/components/dashboard/CollapsibleSetupBanner.tsx
Normal file
230
frontend/src/components/dashboard/CollapsibleSetupBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
frontend/src/components/dashboard/GlanceableHealthHero.tsx
Normal file
371
frontend/src/components/dashboard/GlanceableHealthHero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
237
frontend/src/components/dashboard/SetupWizardBlocker.tsx
Normal file
237
frontend/src/components/dashboard/SetupWizardBlocker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
677
frontend/src/components/dashboard/StockReceiptModal.tsx
Normal file
677
frontend/src/components/dashboard/StockReceiptModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
83
frontend/src/components/domain/dashboard/PriorityBadge.tsx
Normal file
83
frontend/src/components/domain/dashboard/PriorityBadge.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { KeyValueEditor } from './KeyValueEditor';
|
export { KeyValueEditor } from './KeyValueEditor';
|
||||||
export default from './KeyValueEditor';
|
export default KeyValueEditor;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
318
frontend/src/hooks/useEventNotifications.ts
Normal file
318
frontend/src/hooks/useEventNotifications.ts
Normal 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',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
230
frontend/src/hooks/useRecommendations.ts
Normal file
230
frontend/src/hooks/useRecommendations.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
238
frontend/src/hooks/useSSE.ts
Normal file
238
frontend/src/hooks/useSSE.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
14
frontend/src/pages/app/CommunicationsPage.tsx
Normal file
14
frontend/src/pages/app/CommunicationsPage.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ export const routesConfig: RouteConfig[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// Settings Section
|
// Settings Section
|
||||||
{
|
{
|
||||||
path: '/app/settings',
|
path: '/app/settings',
|
||||||
|
|||||||
219
frontend/src/types/alerts.ts
Normal file
219
frontend/src/types/alerts.ts
Normal 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);
|
||||||
|
}
|
||||||
369
frontend/src/types/events.ts
Normal file
369
frontend/src/types/events.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
frontend/src/utils/alertI18n.ts
Normal file
152
frontend/src/utils/alertI18n.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
@@ -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)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
305
services/alert_processor/app/api/internal_demo.py
Normal file
305
services/alert_processor/app/api/internal_demo.py
Normal 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))
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
56
services/alert_processor/app/dependencies.py
Normal file
56
services/alert_processor/app/dependencies.py
Normal 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
|
||||||
12
services/alert_processor/app/jobs/__init__.py
Normal file
12
services/alert_processor/app/jobs/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
44
services/alert_processor/app/jobs/__main__.py
Normal file
44
services/alert_processor/app/jobs/__main__.py
Normal 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())
|
||||||
337
services/alert_processor/app/jobs/priority_recalculation.py
Normal file
337
services/alert_processor/app/jobs/priority_recalculation.py
Normal 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()
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
@@ -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)
|
|
||||||
402
services/alert_processor/app/models/events.py
Normal file
402
services/alert_processor/app/models/events.py
Normal 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'),
|
||||||
|
)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user