Fix and UI imporvements 3

This commit is contained in:
Urtzi Alfaro
2025-12-10 11:23:53 +01:00
parent 46f5158536
commit e116ac244c
20 changed files with 2311 additions and 2948 deletions

View File

@@ -0,0 +1,317 @@
/**
* Unified Dashboard Data Hook
*
* Single data fetch for all 4 dashboard blocks.
* Fetches data once and computes derived values for efficiency.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { alertService } from '../services/alertService';
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
import { productionService } from '../services/production';
import { ProcurementService } from '../services/procurement-service';
import * as orchestratorService from '../services/orchestrator';
import { suppliersService } from '../services/suppliers';
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
import { useSSEEvents } from '../../hooks/useSSE';
// ============================================================
// Types
// ============================================================
export interface DashboardData {
// Raw data from APIs
alerts: any[];
pendingPOs: any[];
productionBatches: any[];
deliveries: any[];
orchestrationSummary: OrchestrationSummary | null;
// Computed/derived data
preventedIssues: any[];
issuesRequiringAction: number;
issuesPreventedByAI: number;
// Filtered data for blocks
overdueDeliveries: any[];
pendingDeliveries: any[];
lateToStartBatches: any[];
runningBatches: any[];
pendingBatches: any[];
}
export interface OrchestrationSummary {
runTimestamp: string | null;
runNumber?: number;
status: string;
purchaseOrdersCreated: number;
productionBatchesCreated: number;
userActionsRequired: number;
aiHandlingRate?: number;
estimatedSavingsEur?: number;
}
// ============================================================
// Main Hook
// ============================================================
/**
* Unified dashboard data hook.
* Fetches ALL data needed by the 4 dashboard blocks in a single parallel request.
*
* @param tenantId - Tenant identifier
* @returns Dashboard data for all blocks
*/
export function useDashboardData(tenantId: string) {
const queryClient = useQueryClient();
const query = useQuery<DashboardData>({
queryKey: ['dashboard-data', tenantId],
queryFn: async () => {
const today = new Date().toISOString().split('T')[0];
const now = new Date();
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment)
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers] = await Promise.all([
alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []),
getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []),
productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })),
ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })),
orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null),
suppliersService.getSuppliers(tenantId).catch(() => []),
]);
// Normalize alerts (API returns array directly or {items: []})
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
const productionBatches = productionResponse?.batches || [];
const deliveries = deliveriesResponse?.deliveries || [];
// Create supplier ID -> supplier name map for quick lookup
const supplierMap = new Map<string, string>();
(suppliers || []).forEach((supplier: any) => {
supplierMap.set(supplier.id, supplier.name || supplier.supplier_name);
});
// Compute derived data - prevented issues and action-needed counts
const preventedIssues = alerts.filter((a: any) => a.type_class === 'prevented_issue');
const actionNeededAlerts = alerts.filter((a: any) =>
a.type_class === 'action_needed' &&
!a.hidden_from_ui &&
a.status === 'active'
);
// Find PO approval alerts to get reasoning data
const poApprovalAlerts = alerts.filter((a: any) =>
a.event_type === 'po_approval_needed' ||
a.event_type === 'purchase_order_created'
);
// Create a map of PO ID -> reasoning data from alerts
const poReasoningMap = new Map<string, any>();
poApprovalAlerts.forEach((alert: any) => {
// Get PO ID from multiple possible locations
const poId = alert.event_metadata?.po_id ||
alert.entity_links?.purchase_order ||
alert.entity_id ||
alert.metadata?.purchase_order_id ||
alert.reference_id;
// Get reasoning data from multiple possible locations
const reasoningData = alert.event_metadata?.reasoning_data ||
alert.ai_reasoning_details ||
alert.reasoning_data ||
alert.ai_reasoning ||
alert.metadata?.reasoning;
// Get supplier name from reasoning data (which has the actual name, not the placeholder)
const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name;
if (poId && reasoningData) {
poReasoningMap.set(poId, {
reasoning_data: reasoningData,
ai_reasoning_summary: alert.ai_reasoning_summary || alert.description || alert.i18n?.message_key,
supplier_name_from_alert: supplierNameFromReasoning, // Real supplier name from alert reasoning
});
}
});
// Enrich POs with reasoning data from alerts AND supplier names
const enrichedPendingPOs = (pendingPOs || []).map((po: any) => {
const reasoningInfo = poReasoningMap.get(po.id);
// Prioritize supplier name from alert reasoning (has actual name in demo data)
const supplierName = reasoningInfo?.supplier_name_from_alert ||
supplierMap.get(po.supplier_id) ||
po.supplier_name;
return {
...po,
supplier_name: supplierName, // Enrich with actual supplier name
reasoning_data: reasoningInfo?.reasoning_data,
ai_reasoning_summary: reasoningInfo?.ai_reasoning_summary,
};
});
// Filter deliveries by status
const isPending = (status: string) =>
status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed';
const overdueDeliveries = deliveries.filter((d: any) => {
if (!isPending(d.status)) return false;
const expectedDate = new Date(d.expected_delivery_date);
return expectedDate < now;
}).map((d: any) => ({
...d,
hoursOverdue: Math.ceil((now.getTime() - new Date(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
}));
const pendingDeliveriesFiltered = deliveries.filter((d: any) => {
if (!isPending(d.status)) return false;
const expectedDate = new Date(d.expected_delivery_date);
return expectedDate >= now;
}).map((d: any) => ({
...d,
hoursUntil: Math.ceil((new Date(d.expected_delivery_date).getTime() - now.getTime()) / (1000 * 60 * 60)),
}));
// Filter production batches by status
const lateToStartBatches = productionBatches.filter((b: any) => {
const status = b.status?.toUpperCase();
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
const plannedStart = b.planned_start_time;
if (!plannedStart) return false;
return new Date(plannedStart) < now;
}).map((b: any) => ({
...b,
hoursLate: Math.ceil((now.getTime() - new Date(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
}));
const runningBatches = productionBatches.filter((b: any) =>
b.status?.toUpperCase() === 'IN_PROGRESS'
);
const pendingBatchesFiltered = productionBatches.filter((b: any) => {
const status = b.status?.toUpperCase();
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
const plannedStart = b.planned_start_time;
if (!plannedStart) return true; // No planned start, count as pending
return new Date(plannedStart) >= now;
});
// Build orchestration summary
// Note: The API only returns timestamp and runNumber, other stats are computed/estimated
let orchestrationSummary: OrchestrationSummary | null = null;
if (orchestration && orchestration.timestamp) {
orchestrationSummary = {
runTimestamp: orchestration.timestamp,
runNumber: orchestration.runNumber ?? undefined,
status: 'completed',
purchaseOrdersCreated: enrichedPendingPOs.length, // Estimate from pending POs
productionBatchesCreated: productionBatches.length,
userActionsRequired: actionNeededAlerts.length,
aiHandlingRate: preventedIssues.length > 0
? Math.round((preventedIssues.length / (preventedIssues.length + actionNeededAlerts.length)) * 100)
: undefined,
estimatedSavingsEur: preventedIssues.length * 50, // Rough estimate: €50 per prevented issue
};
}
return {
// Raw data
alerts,
pendingPOs: enrichedPendingPOs,
productionBatches,
deliveries,
orchestrationSummary,
// Computed
preventedIssues,
issuesRequiringAction: actionNeededAlerts.length,
issuesPreventedByAI: preventedIssues.length,
// Filtered for blocks
overdueDeliveries,
pendingDeliveries: pendingDeliveriesFiltered,
lateToStartBatches,
runningBatches,
pendingBatches: pendingBatchesFiltered,
};
},
enabled: !!tenantId,
staleTime: 20000, // 20 seconds
refetchOnMount: 'always',
retry: 2,
});
return query;
}
// ============================================================
// Real-time SSE Hook
// ============================================================
/**
* Real-time dashboard synchronization via SSE.
* Invalidates the dashboard-data query when relevant events occur.
*
* @param tenantId - Tenant identifier
*/
export function useDashboardRealtimeSync(tenantId: string) {
const queryClient = useQueryClient();
// Subscribe to SSE notifications
const { notifications: batchNotifications } = useBatchNotifications();
const { notifications: deliveryNotifications } = useDeliveryNotifications();
const { recentNotifications: orchestrationNotifications } = useOrchestrationNotifications();
const { events: alertEvents } = useSSEEvents({ channels: ['*.alerts'] });
// Invalidate dashboard data on batch events
useEffect(() => {
if (batchNotifications.length === 0 || !tenantId) return;
const latest = batchNotifications[0];
if (['batch_completed', 'batch_started', 'batch_state_changed'].includes(latest.event_type)) {
queryClient.invalidateQueries({
queryKey: ['dashboard-data', tenantId],
refetchType: 'active',
});
}
}, [batchNotifications, tenantId, queryClient]);
// Invalidate dashboard data on delivery events
useEffect(() => {
if (deliveryNotifications.length === 0 || !tenantId) return;
const latest = deliveryNotifications[0];
if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) {
queryClient.invalidateQueries({
queryKey: ['dashboard-data', tenantId],
refetchType: 'active',
});
}
}, [deliveryNotifications, tenantId, queryClient]);
// Invalidate dashboard data on orchestration events
useEffect(() => {
if (orchestrationNotifications.length === 0 || !tenantId) return;
const latest = orchestrationNotifications[0];
if (latest.event_type === 'orchestration_run_completed') {
queryClient.invalidateQueries({
queryKey: ['dashboard-data', tenantId],
refetchType: 'active',
});
}
}, [orchestrationNotifications, tenantId, queryClient]);
// Invalidate dashboard data on alert events
useEffect(() => {
if (!alertEvents || alertEvents.length === 0 || !tenantId) return;
// Any new alert should trigger a refresh
queryClient.invalidateQueries({
queryKey: ['dashboard-data', tenantId],
refetchType: 'active',
});
}, [alertEvents, tenantId, queryClient]);
}

View File

@@ -54,6 +54,13 @@ export interface SharedDashboardData {
pendingPOs: any[];
delayedBatches: any[];
inventoryData: any;
// Execution progress data for health component
executionProgress?: {
overdueDeliveries: number;
lateToStartBatches: number;
allProductionBatches: any[];
overdueDeliveryDetails?: any[];
};
}
// ============================================================
@@ -64,20 +71,39 @@ function buildChecklistItems(
productionDelays: number,
outOfStock: number,
pendingApprovals: number,
alerts: any[]
alerts: any[],
lateToStartBatches: number,
overdueDeliveries: number
): HealthChecklistItem[] {
const items: HealthChecklistItem[] = [];
// Production status (tri-state)
// Production status (tri-state) - includes ON_HOLD batches + late-to-start batches
const productionPrevented = alerts.filter(
a => a.type_class === 'prevented_issue' && a.alert_type?.includes('production')
);
if (productionDelays > 0) {
const totalProductionIssues = productionDelays + lateToStartBatches;
if (totalProductionIssues > 0) {
// Build detailed message based on what types of issues exist
let textKey = 'dashboard.health.production_issues';
let textParams: any = { total: totalProductionIssues };
if (productionDelays > 0 && lateToStartBatches > 0) {
textKey = 'dashboard.health.production_delayed_and_late';
textParams = { delayed: productionDelays, late: lateToStartBatches };
} else if (productionDelays > 0) {
textKey = 'dashboard.health.production_delayed';
textParams = { count: productionDelays };
} else if (lateToStartBatches > 0) {
textKey = 'dashboard.health.production_late_to_start';
textParams = { count: lateToStartBatches };
}
items.push({
icon: 'alert',
textKey: 'dashboard.health.production_delayed',
textParams: { count: productionDelays },
textKey,
textParams,
actionRequired: true,
status: 'needs_you',
actionPath: '/dashboard'
@@ -162,20 +188,28 @@ function buildChecklistItems(
});
}
// Delivery status (tri-state)
const deliveryAlerts = alerts.filter(
a => a.alert_type?.includes('delivery')
// Delivery status (tri-state) - use actual overdue count from execution progress
const deliveryPrevented = alerts.filter(
a => a.type_class === 'prevented_issue' && a.alert_type?.includes('delivery')
);
if (deliveryAlerts.length > 0) {
if (overdueDeliveries > 0) {
items.push({
icon: 'warning',
textKey: 'dashboard.health.deliveries_pending',
textParams: { count: deliveryAlerts.length },
icon: 'alert',
textKey: 'dashboard.health.deliveries_overdue',
textParams: { count: overdueDeliveries },
actionRequired: true,
status: 'needs_you',
actionPath: '/dashboard'
});
} else if (deliveryPrevented.length > 0) {
items.push({
icon: 'ai_handled',
textKey: 'dashboard.health.deliveries_ai_prevented',
textParams: { count: deliveryPrevented.length },
actionRequired: false,
status: 'ai_handled'
});
} else {
items.push({
icon: 'check',
@@ -267,8 +301,8 @@ export function useSharedDashboardData(tenantId: string) {
return useQuery<SharedDashboardData>({
queryKey: ['shared-dashboard-data', tenantId],
queryFn: async () => {
// Fetch data from 4 services in parallel - ONCE per dashboard load
const [alertsResponse, pendingPOs, delayedBatchesResp, inventoryData] = await Promise.all([
// Fetch data from services in parallel - ONCE per dashboard load
const [alertsResponse, pendingPOs, delayedBatchesResp, inventoryData, executionProgressResp] = await Promise.all([
// CHANGED: Add status=active filter and limit to 100 (backend max)
alertService.getEvents(tenantId, {
status: 'active',
@@ -277,6 +311,61 @@ export function useSharedDashboardData(tenantId: string) {
getPendingApprovalPurchaseOrders(tenantId, 100),
productionService.getBatches(tenantId, { status: ProductionStatus.ON_HOLD, page_size: 100 }),
inventoryService.getDashboardSummary(tenantId),
// NEW: Fetch execution progress for timing data
(async () => {
try {
// Fetch production batches and deliveries for timing calculations
const [prodBatches, deliveries] = await Promise.all([
productionService.getBatches(tenantId, {
start_date: new Date().toISOString().split('T')[0],
page_size: 100
}),
ProcurementService.getExpectedDeliveries(tenantId, {
days_ahead: 1,
include_overdue: true
}),
]);
// Calculate late-to-start batches (batches that should have started but haven't)
const now = new Date();
const allBatches = prodBatches?.batches || [];
const lateToStart = allBatches.filter((b: any) => {
// Only check PENDING or SCHEDULED batches (not started yet)
if (b.status !== 'PENDING' && b.status !== 'SCHEDULED') return false;
// Check if batch has a planned start time
const plannedStart = b.planned_start_time;
if (!plannedStart) return false;
// Check if planned start time is in the past (late to start)
return new Date(plannedStart) < now;
});
// Calculate overdue deliveries (pending deliveries with past due date)
const allDelivs = deliveries?.deliveries || [];
const isPending = (s: string) =>
s === 'PENDING' || s === 'sent_to_supplier' || s === 'confirmed';
const overdueDelivs = allDelivs.filter((d: any) =>
isPending(d.status) && new Date(d.expected_delivery_date) < now
);
return {
overdueDeliveries: overdueDelivs.length,
lateToStartBatches: lateToStart.length,
allProductionBatches: allBatches,
overdueDeliveryDetails: overdueDelivs,
};
} catch (err) {
// Fail gracefully - health will still work without execution progress
console.error('Failed to fetch execution progress for health:', err);
return {
overdueDeliveries: 0,
lateToStartBatches: 0,
allProductionBatches: [],
overdueDeliveryDetails: [],
};
}
})(),
]);
// FIX: Alert API returns array directly, not {items: []}
@@ -287,6 +376,7 @@ export function useSharedDashboardData(tenantId: string) {
pendingPOs: pendingPOs || [],
delayedBatches: delayedBatchesResp?.batches || [],
inventoryData: inventoryData || {},
executionProgress: executionProgressResp,
};
},
enabled: !!tenantId,
@@ -335,23 +425,47 @@ export function useBakeryHealthStatus(tenantId: string, sharedData?: SharedDashb
const criticalAlerts = alerts.filter((a: any) => a.priority_level === 'CRITICAL').length;
const aiPreventedCount = alerts.filter((a: any) => a.type_class === 'prevented_issue').length;
const pendingApprovals = pendingPOs.length;
const productionDelays = delayedBatches.length;
const productionDelays = delayedBatches.length; // ON_HOLD batches
const outOfStock = inventoryData?.out_of_stock_items || 0;
// Calculate health status (same logic as Python backend lines 245-268)
// Extract execution progress data (operational delays)
const executionProgress = sharedData?.executionProgress || {
overdueDeliveries: 0,
lateToStartBatches: 0,
allProductionBatches: [],
overdueDeliveryDetails: []
};
const overdueDeliveries = executionProgress.overdueDeliveries;
const lateToStartBatches = executionProgress.lateToStartBatches;
// Calculate health status - UPDATED to include operational delays
let status: 'green' | 'yellow' | 'red' = 'green';
if (criticalAlerts >= 3 || outOfStock > 0 || productionDelays > 2) {
// Red conditions: Include operational delays (overdue deliveries, late batches)
if (
criticalAlerts >= 3 ||
outOfStock > 0 ||
productionDelays > 2 ||
overdueDeliveries > 0 || // NEW: Any overdue delivery = red
lateToStartBatches > 0 // NEW: Any late batch = red
) {
status = 'red';
} else if (criticalAlerts > 0 || pendingApprovals > 0 || productionDelays > 0) {
} else if (
criticalAlerts > 0 ||
pendingApprovals > 0 ||
productionDelays > 0
) {
status = 'yellow';
}
// Generate tri-state checklist (same logic as Python backend lines 93-223)
// Generate tri-state checklist with operational delays
const checklistItems = buildChecklistItems(
productionDelays,
outOfStock,
pendingApprovals,
alerts
alerts,
lateToStartBatches, // NEW
overdueDeliveries // NEW
);
// Get last orchestration run timestamp from orchestrator DB
@@ -372,7 +486,9 @@ export function useBakeryHealthStatus(tenantId: string, sharedData?: SharedDashb
nextScheduledRun: nextRun.toISOString(),
checklistItems,
criticalIssues: criticalAlerts,
pendingActions: pendingApprovals + productionDelays + outOfStock,
// UPDATED: Include all operational delays (approvals, delays, stock, deliveries, late batches)
pendingActions: pendingApprovals + productionDelays + outOfStock +
overdueDeliveries + lateToStartBatches,
aiPreventedIssues: aiPreventedCount,
};
},

View File

@@ -1,548 +0,0 @@
// ================================================================
// frontend/src/components/dashboard/ExecutionProgressTracker.tsx
// ================================================================
/**
* Execution Progress Tracker - Plan vs Actual
*
* Shows how today's execution is progressing vs the plan.
* Helps identify bottlenecks early (e.g., deliveries running late).
*
* Features:
* - Production progress (plan vs actual batches)
* - Delivery status (received, pending, overdue)
* - Approval tracking
* - "What's next" preview
* - Status indicators (on_track, at_risk, completed)
*/
import React from 'react';
import {
Package,
Truck,
CheckCircle,
Clock,
AlertCircle,
TrendingUp,
Calendar,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatTime as formatTimeUtil } from '../../utils/date';
// ============================================================
// Types
// ============================================================
export interface ProductionProgress {
status: 'no_plan' | 'completed' | 'on_track' | 'at_risk';
total: number;
completed: number;
inProgress: number;
pending: number;
inProgressBatches?: Array<{
id: string;
batchNumber: string;
productName: string;
quantity: number;
actualStartTime: string;
estimatedCompletion: string;
}>;
nextBatch?: {
productName: string;
plannedStart: string; // ISO datetime
batchNumber: string;
};
}
export interface DeliveryInfo {
poId: string;
poNumber: string;
supplierName: string;
supplierPhone?: string;
expectedDeliveryDate: string;
status: string;
lineItems: Array<{
product_name: string;
quantity: number;
unit: string;
}>;
totalAmount: number;
currency: string;
itemCount: number;
hoursOverdue?: number;
hoursUntil?: number;
}
export interface DeliveryProgress {
status: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk';
total: number;
received: number;
pending: number;
overdue: number;
overdueDeliveries?: DeliveryInfo[];
pendingDeliveries?: DeliveryInfo[];
receivedDeliveries?: DeliveryInfo[];
}
export interface ApprovalProgress {
status: 'completed' | 'on_track' | 'at_risk';
pending: number;
}
export interface ExecutionProgress {
production: ProductionProgress;
deliveries: DeliveryProgress;
approvals: ApprovalProgress;
}
interface ExecutionProgressTrackerProps {
progress: ExecutionProgress | null | undefined;
loading?: boolean;
}
// ============================================================
// Helper Functions
// ============================================================
function getStatusColor(status: string): {
bg: string;
border: string;
text: string;
icon: string;
} {
switch (status) {
case 'completed':
return {
bg: 'var(--color-success-50)',
border: 'var(--color-success-300)',
text: 'var(--color-success-900)',
icon: 'var(--color-success-600)',
};
case 'on_track':
return {
bg: 'var(--color-info-50)',
border: 'var(--color-info-300)',
text: 'var(--color-info-900)',
icon: 'var(--color-info-600)',
};
case 'at_risk':
return {
bg: 'var(--color-error-50)',
border: 'var(--color-error-300)',
text: 'var(--color-error-900)',
icon: 'var(--color-error-600)',
};
case 'no_plan':
case 'no_deliveries':
return {
bg: 'var(--bg-secondary)',
border: 'var(--border-secondary)',
text: 'var(--text-secondary)',
icon: 'var(--text-tertiary)',
};
default:
return {
bg: 'var(--bg-secondary)',
border: 'var(--border-secondary)',
text: 'var(--text-primary)',
icon: 'var(--text-secondary)',
};
}
}
function formatTime(isoDate: string): string {
return formatTimeUtil(isoDate, 'HH:mm');
}
// ============================================================
// Sub-Components
// ============================================================
interface SectionProps {
title: string;
icon: React.ElementType;
status: string;
statusLabel: string;
children: React.ReactNode;
}
function Section({ title, icon: Icon, status, statusLabel, children }: SectionProps) {
const colors = getStatusColor(status);
return (
<div
className="rounded-lg border-2 p-4"
style={{
backgroundColor: colors.bg,
borderColor: colors.border,
}}
>
{/* Section Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Icon className="w-5 h-5" style={{ color: colors.icon }} />
<h3 className="font-bold" style={{ color: colors.text }}>
{title}
</h3>
</div>
<span
className="px-3 py-1 rounded-full text-xs font-semibold"
style={{
backgroundColor: colors.border,
color: 'white',
}}
>
{statusLabel}
</span>
</div>
{/* Section Content */}
{children}
</div>
);
}
// ============================================================
// Main Component
// ============================================================
export function ExecutionProgressTracker({
progress,
loading,
}: ExecutionProgressTrackerProps) {
const { t } = useTranslation(['dashboard', 'common']);
if (loading) {
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/3" 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 className="h-32 rounded" style={{ backgroundColor: 'var(--bg-tertiary)' }}></div>
</div>
</div>
);
}
if (!progress) {
return null;
}
return (
<div
className="rounded-xl shadow-xl p-6 border-2 transition-all duration-300 hover:shadow-2xl"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Header with Hero Icon */}
<div className="flex items-center gap-4 mb-6">
{/* Hero 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: 'var(--color-primary-100)' }}
>
<TrendingUp className="w-8 h-8 md:w-10 md:h-10" strokeWidth={2.5} style={{ color: 'var(--color-primary-600)' }} />
</div>
{/* Title */}
<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:execution_progress.title')}
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.subtitle')}
</p>
</div>
</div>
<div className="space-y-4">
{/* Production Section */}
<Section
title={t('dashboard:execution_progress.production')}
icon={Package}
status={progress.production.status}
statusLabel={t(`dashboard:execution_progress.status.${progress.production.status}`)}
>
{progress.production.status === 'no_plan' ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.no_production_plan')}
</p>
) : (
<>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-sm mb-2">
<span style={{ color: 'var(--text-secondary)' }}>
{progress.production.completed} / {progress.production.total} {t('dashboard:execution_progress.batches_complete')}
</span>
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{Math.round((progress.production.completed / progress.production.total) * 100)}%
</span>
</div>
<div
className="h-3 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--bg-tertiary)' }}
>
<div
className="h-full transition-all duration-500"
style={{
width: `${(progress.production.completed / progress.production.total) * 100}%`,
backgroundColor:
progress.production.status === 'at_risk'
? 'var(--color-error-500)'
: progress.production.status === 'completed'
? 'var(--color-success-500)'
: 'var(--color-info-500)',
}}
/>
</div>
</div>
{/* Status Breakdown */}
<div className="grid grid-cols-3 gap-2 text-sm">
<div>
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.completed')}:</span>
<span className="ml-2 font-semibold" style={{ color: 'var(--color-success-700)' }}>
{progress.production.completed}
</span>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.in_progress')}:</span>
<span className="ml-2 font-semibold" style={{ color: 'var(--color-info-700)' }}>
{progress.production.inProgress}
</span>
{progress.production.inProgressBatches && progress.production.inProgressBatches.length > 0 && (
<div className="ml-6 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
{progress.production.inProgressBatches.map((batch) => (
<div key={batch.id} className="flex items-center gap-2">
<span> {batch.productName}</span>
<span className="opacity-60">({batch.batchNumber})</span>
</div>
))}
</div>
)}
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>{t('dashboard:execution_progress.pending')}:</span>
<span className="ml-2 font-semibold" style={{ color: 'var(--text-tertiary)' }}>
{progress.production.pending}
</span>
</div>
</div>
{/* Next Batch */}
{progress.production.nextBatch && (
<div
className="mt-3 p-3 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-secondary)',
}}
>
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4" style={{ color: 'var(--color-info)' }} />
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.whats_next')}
</span>
</div>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{progress.production.nextBatch.productName}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{progress.production.nextBatch.batchNumber} · {t('dashboard:execution_progress.starts_at')}{' '}
{formatTime(progress.production.nextBatch.plannedStart)}
</p>
</div>
)}
</>
)}
</Section>
{/* Deliveries Section */}
<Section
title={t('dashboard:execution_progress.deliveries')}
icon={Truck}
status={progress.deliveries.status}
statusLabel={t(`dashboard:execution_progress.status.${progress.deliveries.status}`)}
>
{progress.deliveries.status === 'no_deliveries' ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.no_deliveries_today')}
</p>
) : (
<>
{/* Summary Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<CheckCircle className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-success-700)' }}>
{progress.deliveries.received}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.received')}
</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<Clock className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-info-700)' }}>
{progress.deliveries.pending}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.pending')}
</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center mb-1">
<AlertCircle className="w-4 h-4" style={{ color: 'var(--color-error-600)' }} />
</div>
<div className="text-2xl font-bold" style={{ color: 'var(--color-error-700)' }}>
{progress.deliveries.overdue}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.overdue')}
</div>
</div>
</div>
{/* Overdue Deliveries List */}
{progress.deliveries.overdueDeliveries && progress.deliveries.overdueDeliveries.length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-error-700)' }}>
<AlertCircle className="w-3 h-3" />
{t('dashboard:execution_progress.overdue_deliveries')}
</div>
<div className="space-y-2">
{progress.deliveries.overdueDeliveries.map((delivery) => (
<div
key={delivery.poId}
className="p-3 rounded-lg border"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-200)',
}}
>
<div className="flex items-start justify-between mb-1">
<div>
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{delivery.supplierName}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{delivery.poNumber} · {delivery.hoursOverdue}h {t('dashboard:execution_progress.overdue_label')}
</div>
</div>
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{delivery.totalAmount.toFixed(2)} {delivery.currency}
</div>
</div>
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
{delivery.lineItems.slice(0, 2).map((item, idx) => (
<div key={idx}> {item.product_name} ({item.quantity} {item.unit})</div>
))}
{delivery.itemCount > 2 && (
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Pending Deliveries List */}
{progress.deliveries.pendingDeliveries && progress.deliveries.pendingDeliveries.length > 0 && (
<div>
<div className="text-xs font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--color-info-700)' }}>
<Clock className="w-3 h-3" />
{t('dashboard:execution_progress.pending_deliveries')}
</div>
<div className="space-y-2">
{progress.deliveries.pendingDeliveries.slice(0, 3).map((delivery) => (
<div
key={delivery.poId}
className="p-3 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-secondary)',
}}
>
<div className="flex items-start justify-between mb-1">
<div>
<div className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{delivery.supplierName}
</div>
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{delivery.poNumber} · {delivery.hoursUntil !== undefined && delivery.hoursUntil >= 0
? `${t('dashboard:execution_progress.arriving_in')} ${delivery.hoursUntil}h`
: formatTime(delivery.expectedDeliveryDate)}
</div>
</div>
<div className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>
{delivery.totalAmount.toFixed(2)} {delivery.currency}
</div>
</div>
<div className="text-xs mt-2" style={{ color: 'var(--text-tertiary)' }}>
{delivery.lineItems.slice(0, 2).map((item, idx) => (
<div key={idx}> {item.product_name} ({item.quantity} {item.unit})</div>
))}
{delivery.itemCount > 2 && (
<div>+ {delivery.itemCount - 2} {t('dashboard:execution_progress.more_items')}</div>
)}
</div>
</div>
))}
{progress.deliveries.pendingDeliveries.length > 3 && (
<div className="text-xs text-center py-1" style={{ color: 'var(--text-tertiary)' }}>
+ {progress.deliveries.pendingDeliveries.length - 3} {t('dashboard:execution_progress.more_deliveries')}
</div>
)}
</div>
</div>
)}
</>
)}
</Section>
{/* Approvals Section */}
<Section
title={t('dashboard:execution_progress.approvals')}
icon={Calendar}
status={progress.approvals.status}
statusLabel={t(`dashboard:execution_progress.status.${progress.approvals.status}`)}
>
<div className="flex items-center justify-between">
<span style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:execution_progress.pending_approvals')}
</span>
<span
className="text-3xl font-bold"
style={{
color:
progress.approvals.status === 'at_risk'
? 'var(--color-error-700)'
: progress.approvals.status === 'completed'
? 'var(--color-success-700)'
: 'var(--color-info-700)',
}}
>
{progress.approvals.pending}
</span>
</div>
</Section>
</div>
</div>
);
}

View File

@@ -1,382 +0,0 @@
// ================================================================
// 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/useProfessionalDashboard';
import { formatDistanceToNow } from 'date-fns';
import { es, eu, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useEventNotifications } from '../../hooks/useEventNotifications';
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 } = useEventNotifications();
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;
// Filter prevented issues from last 7 days to match IntelligentSystemSummaryCard
const preventedIssuesCount = useMemo(() => {
if (!notifications || notifications.length === 0) return 0;
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return notifications.filter(
n => n.type_class === 'prevented_issue' && new Date(n.timestamp) >= sevenDaysAgo
).length;
}, [notifications]);
// Create stable key for checklist items to prevent infinite re-renders
const checklistItemsKey = useMemo(() => {
if (!healthStatus?.checklistItems || healthStatus.checklistItems.length === 0) return 'empty';
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 - Show last 7 days to match detail section */}
{preventedIssuesCount > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-info-100)', color: 'var(--color-info-800)' }}>
<Zap className="w-4 h-4" />
<span className="font-semibold">{preventedIssuesCount} evitado{preventedIssuesCount > 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
{/* Expand/Collapse Button */}
<button
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="flex-shrink-0 p-2 rounded-lg transition-colors hover:bg-[var(--bg-tertiary)]"
title={detailsExpanded ? 'Ocultar detalles' : 'Ver detalles'}
>
{detailsExpanded ? (
<ChevronUp className="w-6 h-6" style={{ color: config.textColor }} />
) : (
<ChevronDown className="w-6 h-6" style={{ color: config.textColor }} />
)}
</button>
</div>
</div>
{/* Detailed Checklist (Collapsible) */}
{detailsExpanded && (
<div
className="px-6 pb-6 pt-2 border-t border-[var(--border-primary)]"
style={{ borderColor: config.borderColor }}
>
{/* Full Headline */}
<p className="text-base mb-4 text-[var(--text-secondary)]">
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
? translateKey(healthStatus.headline.key, healthStatus.headline.params || {}, t)
: healthStatus.headline}
</p>
{/* Checklist */}
{updatedChecklistItems && updatedChecklistItems.length > 0 && (
<div className="space-y-2">
{updatedChecklistItems.map((item, index) => {
// Safely get the icon with proper validation
const SafeIconComponent = iconMap[item.icon];
const ItemIcon = SafeIconComponent || AlertCircle;
const getStatusStyles = () => {
switch (item.status) {
case 'good':
return {
iconColor: 'var(--color-success-600)',
bgColor: 'var(--color-success-50)',
borderColor: 'transparent',
};
case 'ai_handled':
return {
iconColor: 'var(--color-info-600)',
bgColor: 'var(--color-info-50)',
borderColor: 'var(--color-info-300)',
};
case 'needs_you':
return {
iconColor: 'var(--color-warning-600)',
bgColor: 'var(--color-warning-50)',
borderColor: 'var(--color-warning-300)',
};
default:
return {
iconColor: item.actionRequired ? 'var(--color-warning-600)' : 'var(--color-success-600)',
bgColor: item.actionRequired ? 'var(--bg-primary)' : 'var(--bg-secondary)',
borderColor: 'transparent',
};
}
};
const styles = getStatusStyles();
const displayText = item.textKey
? translateKey(item.textKey, item.textParams || {}, t)
: item.text || '';
const handleClick = () => {
if (item.actionPath && (item.status === 'needs_you' || item.actionRequired)) {
navigate(item.actionPath);
}
};
const isClickable = Boolean(item.actionPath && (item.status === 'needs_you' || item.actionRequired));
return (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border transition-all ${
isClickable ? 'cursor-pointer hover:shadow-md hover:scale-[1.01] bg-[var(--bg-tertiary)]' : ''
}`}
style={{
backgroundColor: styles.bgColor,
borderColor: styles.borderColor,
}}
onClick={handleClick}
>
<ItemIcon className="w-5 h-5 flex-shrink-0" style={{ color: styles.iconColor }} />
<span
className={`flex-1 text-sm ${
item.status === 'needs_you' || item.actionRequired ? 'font-semibold' : ''
} text-[var(--text-primary)]`}
>
{displayText}
</span>
{isClickable && (
<ChevronRight className="w-4 h-4 flex-shrink-0 text-[var(--text-tertiary)]" />
)}
</div>
);
})}
</div>
)}
{/* Next Check */}
{healthStatus.nextScheduledRun && (
<div className="flex items-center gap-2 text-sm mt-4 p-3 rounded-lg bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
<RefreshCw className="w-4 h-4" />
<span>
{t('jtbd.health_status.next_check', { ns: 'reasoning' })}:{' '}
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
</span>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,529 +0,0 @@
// ================================================================
// 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,
Package,
} from 'lucide-react';
import { OrchestrationSummary } from '../../api/hooks/useProfessionalDashboard';
import { useTranslation } from 'react-i18next';
import { formatTime, formatRelativeTime } from '../../utils/date';
import { useTenant } from '../../stores/tenant.store';
import { useEventNotifications } from '../../hooks/useEventNotifications';
import { Alert } from '../../api/types/events';
import { renderEventTitle, renderEventMessage } from '../../utils/i18n/alertRendering';
import { Badge } from '../ui/Badge';
interface PeriodComparison {
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 } = useEventNotifications();
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [preventedAlerts, setPreventedAlerts] = useState<Alert[]>([]);
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: Alert[] }>(
`/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 - merge with API data
const allPreventedAlerts = useMemo(() => {
if (!notifications || notifications.length === 0) return preventedAlerts;
// Filter SSE notifications for prevented issues from last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const ssePreventedIssues = notifications.filter(
(n) => n.type_class === 'prevented_issue' && new Date(n.created_at) >= sevenDaysAgo
);
// Deduplicate: combine SSE + API data, removing duplicates by ID
const existingIds = new Set(preventedAlerts.map((a) => a.id));
const newSSEAlerts = ssePreventedIssues.filter((n) => !existingIds.has(n.id));
// Merge and sort by created_at (newest first)
const merged = [...preventedAlerts, ...newSSEAlerts].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
return merged.slice(0, 20); // Keep only top 20
}, [preventedAlerts, notifications]);
// Calculate metrics
const totalSavings = analytics?.estimated_savings_eur || 0;
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 - Show actual count from last 7 days to match detail section */}
<div
className="flex items-center gap-1 px-2 py-1 rounded-md"
style={{ backgroundColor: 'var(--color-primary-100)', border: '1px solid var(--color-primary-300)' }}
>
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-primary-700)' }}>
{allPreventedAlerts.length}
</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)' }}>
{allPreventedAlerts.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:intelligent_system.no_prevented_issues', 'No issues prevented this week - all systems running smoothly!')}
</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: allPreventedAlerts.length,
})}
</p>
</div>
{/* Prevented Issues List */}
<div className="space-y-2">
{allPreventedAlerts.map((alert) => {
const savings = alert.orchestrator_context?.estimated_savings_eur || 0;
const actionTaken = alert.orchestrator_context?.action_taken || 'AI intervention';
const timeAgo = formatRelativeTime(alert.created_at) || 'Fecha desconocida';
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)' }}>
{renderEventTitle(alert, t)}
</h4>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{renderEventMessage(alert, t)}
</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>
{/* AI Reasoning Section */}
{orchestrationSummary.reasoning && orchestrationSummary.reasoning.reasoning_i18n && (
<div className="mt-4 space-y-3">
{/* Reasoning Text Block */}
<div
className="rounded-lg p-4 border-l-4"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--color-info-600)',
}}
>
<div className="flex items-start gap-2 mb-2">
<Bot className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
<h4 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('alerts:orchestration.reasoning_title', '🤖 Razonamiento del Orquestador Diario')}
</h4>
</div>
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
{t(
orchestrationSummary.reasoning.reasoning_i18n.key,
orchestrationSummary.reasoning.reasoning_i18n.params || {}
)}
</p>
</div>
{/* Business Impact Metrics */}
{(orchestrationSummary.reasoning.business_impact?.financial_impact_eur ||
orchestrationSummary.reasoning.business_impact?.affected_orders) && (
<div className="flex flex-wrap gap-2">
{orchestrationSummary.reasoning.business_impact.financial_impact_eur > 0 && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md"
style={{
backgroundColor: 'var(--color-success-100)',
border: '1px solid var(--color-success-300)',
}}
>
<TrendingUp className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-success-700)' }}>
{orchestrationSummary.reasoning.business_impact.financial_impact_eur.toFixed(0)}{' '}
{t('dashboard:intelligent_system.estimated_savings', 'impacto financiero')}
</span>
</div>
)}
{orchestrationSummary.reasoning.business_impact.affected_orders > 0 && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md"
style={{
backgroundColor: 'var(--color-info-100)',
border: '1px solid var(--color-info-300)',
}}
>
<Package className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-info-700)' }}>
{orchestrationSummary.reasoning.business_impact.affected_orders}{' '}
{t('common:orders', 'pedidos')}
</span>
</div>
)}
</div>
)}
{/* Urgency Context */}
{orchestrationSummary.reasoning.urgency_context?.time_until_consequence_hours > 0 && (
<div
className="rounded-lg p-3 flex items-center gap-2"
style={{
backgroundColor: 'var(--color-warning-50)',
borderLeft: '4px solid var(--color-warning-600)',
}}
>
<Clock className="w-4 h-4" style={{ color: 'var(--color-warning-600)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-warning-800)' }}>
{Math.round(orchestrationSummary.reasoning.urgency_context.time_until_consequence_hours)}h{' '}
{t('common:remaining', 'restantes')}
</span>
</div>
)}
</div>
)}
</div>
) : (
<div className="text-sm text-center py-4" style={{ color: 'var(--text-secondary)' }}>
{t('dashboard:orchestration.no_runs_message', 'No orchestration has been run yet.')}
</div>
)}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,286 @@
/**
* PendingDeliveriesBlock - Block 3: "Entregas Pendientes"
*
* Displays today's delivery status:
* - Overdue deliveries with alert styling
* - Pending deliveries expected today
* - Actions: Call Supplier, Mark Received
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
CheckCircle2,
Clock,
Package,
Phone,
Truck,
} from 'lucide-react';
interface PendingDeliveriesBlockProps {
overdueDeliveries?: any[];
pendingDeliveries?: any[];
onCallSupplier?: (delivery: any) => void;
onMarkReceived?: (poId: string) => void;
loading?: boolean;
}
export function PendingDeliveriesBlock({
overdueDeliveries = [],
pendingDeliveries = [],
onCallSupplier,
onMarkReceived,
loading,
}: PendingDeliveriesBlockProps) {
const { t } = useTranslation(['dashboard', 'common']);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
</div>
</div>
<div className="space-y-3">
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
);
}
const hasOverdue = overdueDeliveries.length > 0;
const hasPending = pendingDeliveries.length > 0;
const hasAnyDeliveries = hasOverdue || hasPending;
const totalCount = overdueDeliveries.length + pendingDeliveries.length;
// Determine header status
const status = hasOverdue ? 'error' : hasPending ? 'warning' : 'success';
const statusStyles = {
success: {
iconBg: 'bg-[var(--color-success-100)]',
iconColor: 'text-[var(--color-success-600)]',
},
warning: {
iconBg: 'bg-[var(--color-warning-100)]',
iconColor: 'text-[var(--color-warning-600)]',
},
error: {
iconBg: 'bg-[var(--color-error-100)]',
iconColor: 'text-[var(--color-error-600)]',
},
};
const styles = statusStyles[status];
// Format hours display
const formatHours = (hours: number) => {
if (hours < 1) return t('common:time.less_than_hour', '< 1h');
if (hours === 1) return t('common:time.one_hour', '1h');
return `${hours}h`;
};
return (
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-4">
{/* Icon */}
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${styles.iconBg}`}>
{hasOverdue ? (
<AlertTriangle className={`w-6 h-6 ${styles.iconColor}`} />
) : hasAnyDeliveries ? (
<Truck className={`w-6 h-6 ${styles.iconColor}`} />
) : (
<CheckCircle2 className={`w-6 h-6 ${styles.iconColor}`} />
)}
</div>
{/* Title & Count */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.pending_deliveries.title')}
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{hasAnyDeliveries
? t('dashboard:new_dashboard.pending_deliveries.count', { count: totalCount })
: t('dashboard:new_dashboard.pending_deliveries.no_deliveries')}
</p>
</div>
{/* Count Badges */}
<div className="flex items-center gap-2">
{hasOverdue && (
<div className="px-3 py-1 rounded-full bg-[var(--color-error-100)] text-[var(--color-error-700)] font-semibold text-sm">
{overdueDeliveries.length} {t('dashboard:new_dashboard.pending_deliveries.overdue_badge')}
</div>
)}
{hasPending && (
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
{pendingDeliveries.length}
</div>
)}
</div>
</div>
</div>
{/* Content */}
{hasAnyDeliveries ? (
<div className="border-t border-[var(--border-primary)]">
{/* Overdue Section */}
{hasOverdue && (
<div className="bg-[var(--color-error-50)]">
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dashboard:new_dashboard.pending_deliveries.overdue_section')}
</h3>
</div>
{overdueDeliveries.map((delivery, index) => (
<div
key={delivery.po_id || index}
className={`p-4 ${
index < overdueDeliveries.length - 1 ? 'border-b border-[var(--color-error-100)]' : ''
}`}
>
<div className="flex items-start gap-4">
{/* Delivery Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-[var(--color-error-600)]" />
<span className="font-semibold text-[var(--text-primary)]">
{delivery.supplier_name || 'Unknown Supplier'}
</span>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-1">
{t('dashboard:new_dashboard.pending_deliveries.po_ref', {
number: delivery.po_number || delivery.po_id?.slice(0, 8),
})}
</p>
{/* Overdue Badge */}
<div className="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--color-error-100)] text-[var(--color-error-700)] text-xs font-medium">
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.pending_deliveries.overdue_by', {
hours: formatHours(delivery.hoursOverdue || 0),
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{delivery.supplier_phone && onCallSupplier && (
<button
onClick={() => onCallSupplier(delivery)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
<Phone className="w-4 h-4" />
<span className="text-sm font-medium hidden sm:inline">
{t('dashboard:new_dashboard.pending_deliveries.call_supplier')}
</span>
</button>
)}
{onMarkReceived && (
<button
onClick={() => onMarkReceived(delivery.po_id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors"
>
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.pending_deliveries.mark_received')}
</span>
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Pending Today Section */}
{hasPending && (
<div>
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<h3 className="text-sm font-semibold text-[var(--text-secondary)] flex items-center gap-2">
<Truck className="w-4 h-4" />
{t('dashboard:new_dashboard.pending_deliveries.today_section')}
</h3>
</div>
{pendingDeliveries.map((delivery, index) => (
<div
key={delivery.po_id || index}
className={`p-4 ${
index < pendingDeliveries.length - 1 ? 'border-b border-[var(--border-primary)]' : ''
}`}
>
<div className="flex items-start gap-4">
{/* Delivery Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Package className="w-4 h-4 text-[var(--text-tertiary)]" />
<span className="font-semibold text-[var(--text-primary)]">
{delivery.supplier_name || 'Unknown Supplier'}
</span>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-1">
{t('dashboard:new_dashboard.pending_deliveries.po_ref', {
number: delivery.po_number || delivery.po_id?.slice(0, 8),
})}
</p>
{/* Arriving Badge */}
<div className="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--color-info-50)] text-[var(--color-info-700)] text-xs font-medium">
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.pending_deliveries.arriving_in', {
hours: formatHours(delivery.hoursUntil || 0),
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{onMarkReceived && (
<button
onClick={() => onMarkReceived(delivery.po_id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors"
>
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.pending_deliveries.mark_received')}
</span>
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
) : (
/* Empty State */
<div className="px-6 pb-6">
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
<p className="text-sm text-[var(--color-success-700)]">
{t('dashboard:new_dashboard.pending_deliveries.all_clear')}
</p>
</div>
</div>
)}
</div>
);
}
export default PendingDeliveriesBlock;

View File

@@ -0,0 +1,321 @@
/**
* PendingPurchasesBlock - Block 2: "Compras Pendientes"
*
* Displays pending purchase orders awaiting approval:
* - PO number, supplier, amount
* - AI reasoning for why the PO was created
* - Inline actions: Approve, Reject, View Details
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Brain,
Check,
CheckCircle2,
ChevronDown,
ChevronUp,
Eye,
ShoppingCart,
X,
} from 'lucide-react';
interface PendingPurchasesBlockProps {
pendingPOs: any[];
onApprove?: (poId: string) => Promise<void>;
onReject?: (poId: string, reason: string) => Promise<void>;
onViewDetails?: (poId: string) => void;
loading?: boolean;
}
export function PendingPurchasesBlock({
pendingPOs = [],
onApprove,
onReject,
onViewDetails,
loading,
}: PendingPurchasesBlockProps) {
const { t } = useTranslation(['dashboard', 'common']);
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
const [processingId, setProcessingId] = useState<string | null>(null);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
</div>
</div>
<div className="space-y-3">
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
);
}
const hasPendingPOs = pendingPOs.length > 0;
// Handle approve action
const handleApprove = async (poId: string) => {
if (!onApprove || processingId) return;
setProcessingId(poId);
try {
await onApprove(poId);
} finally {
setProcessingId(null);
}
};
// Handle reject action
const handleReject = async (poId: string) => {
if (!onReject || processingId) return;
setProcessingId(poId);
try {
await onReject(poId, 'Rejected from dashboard');
} finally {
setProcessingId(null);
}
};
// Toggle reasoning expansion
const toggleReasoning = (poId: string) => {
setExpandedReasoningId(expandedReasoningId === poId ? null : poId);
};
// Format AI reasoning from reasoning_data
const formatReasoning = (po: any): string | null => {
const reasoningData = po.reasoning_data || po.ai_reasoning;
// If no structured reasoning data, try the summary field
if (!reasoningData) {
return po.ai_reasoning_summary || null;
}
if (typeof reasoningData === 'string') return reasoningData;
// Handle structured reasoning data
if (reasoningData.type === 'low_stock_forecast' || reasoningData.type === 'low_stock_detection') {
const params = reasoningData.parameters || {};
const productNames = params.product_names || params.critical_products || [];
const productDetails = params.product_details || [];
const criticalCount = params.critical_product_count || productNames.length;
const minDepletionDays = Math.ceil(params.min_depletion_days || 0);
const affectedBatchesCount = params.affected_batches_count || 0;
const potentialLoss = params.potential_loss_eur || 0;
// If we have detailed data (multiple products), show comprehensive message
if (criticalCount > 1 && productNames.length > 0) {
const productsStr = productNames.slice(0, 3).join(', ') + (productNames.length > 3 ? '...' : '');
return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock_detailed', {
count: criticalCount,
products: productsStr,
days: minDepletionDays,
batches: affectedBatchesCount,
loss: potentialLoss.toFixed(2),
});
}
// Simple version for single product
const firstProduct = productDetails[0] || {};
const ingredient = firstProduct.product_name || productNames[0] || 'ingredient';
const days = Math.ceil(firstProduct.days_until_depletion || minDepletionDays);
return t('dashboard:new_dashboard.pending_purchases.reasoning.low_stock', {
ingredient,
days,
});
}
if (reasoningData.type === 'demand_forecast') {
return t('dashboard:new_dashboard.pending_purchases.reasoning.demand_forecast', {
product: reasoningData.parameters?.product_name || 'product',
increase: reasoningData.parameters?.demand_increase_percent || 0,
});
}
if (reasoningData.summary) return reasoningData.summary;
// Fallback to ai_reasoning_summary if structured data doesn't have a matching type
return po.ai_reasoning_summary || null;
};
return (
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-4">
{/* Icon */}
<div
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${
hasPendingPOs
? 'bg-[var(--color-warning-100)]'
: 'bg-[var(--color-success-100)]'
}`}
>
{hasPendingPOs ? (
<ShoppingCart className="w-6 h-6 text-[var(--color-warning-600)]" />
) : (
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
)}
</div>
{/* Title & Count */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.pending_purchases.title')}
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{hasPendingPOs
? t('dashboard:new_dashboard.pending_purchases.count', {
count: pendingPOs.length,
})
: t('dashboard:new_dashboard.pending_purchases.no_pending')}
</p>
</div>
{/* Count Badge */}
{hasPendingPOs && (
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
{pendingPOs.length}
</div>
)}
</div>
</div>
{/* PO List */}
{hasPendingPOs ? (
<div className="border-t border-[var(--border-primary)]">
{pendingPOs.map((po, index) => {
const poId = po.id || po.po_id;
const isProcessing = processingId === poId;
const isExpanded = expandedReasoningId === poId;
const reasoning = formatReasoning(po);
return (
<div
key={poId || index}
className={`p-4 ${
index < pendingPOs.length - 1 ? 'border-b border-[var(--border-primary)]' : ''
}`}
>
{/* PO Main Info */}
<div className="flex items-start gap-4">
{/* PO Details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.pending_purchases.po_number', {
number: po.po_number || po.id?.slice(0, 8),
})}
</span>
{reasoning && (
<button
onClick={() => toggleReasoning(poId)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-50)] text-[var(--color-primary-700)] hover:bg-[var(--color-primary-100)] transition-colors"
>
<Brain className="w-3 h-3" />
AI
{isExpanded ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</button>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mb-1">
{t('dashboard:new_dashboard.pending_purchases.supplier', {
name: po.supplier_name || po.supplier?.name || 'Unknown',
})}
</p>
<p className="text-lg font-bold text-[var(--text-primary)]">
{(po.total_amount || po.total || 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* View Details */}
{onViewDetails && (
<button
onClick={() => onViewDetails(poId)}
disabled={isProcessing}
className="p-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors disabled:opacity-50"
title={t('dashboard:new_dashboard.pending_purchases.view_details')}
>
<Eye className="w-5 h-5" />
</button>
)}
{/* Reject */}
{onReject && (
<button
onClick={() => handleReject(poId)}
disabled={isProcessing}
className="p-2 rounded-lg border border-[var(--color-error-200)] text-[var(--color-error-600)] hover:bg-[var(--color-error-50)] transition-colors disabled:opacity-50"
title={t('dashboard:new_dashboard.pending_purchases.reject')}
>
<X className="w-5 h-5" />
</button>
)}
{/* Approve */}
{onApprove && (
<button
onClick={() => handleApprove(poId)}
disabled={isProcessing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors disabled:opacity-50"
>
<Check className="w-5 h-5" />
<span className="font-medium">
{t('dashboard:new_dashboard.pending_purchases.approve')}
</span>
</button>
)}
</div>
</div>
{/* AI Reasoning (Expanded) */}
{isExpanded && reasoning && (
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
<div className="flex items-start gap-2">
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
{t('dashboard:new_dashboard.pending_purchases.ai_reasoning')}
</p>
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
) : (
/* Empty State */
<div className="px-6 pb-6">
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
<p className="text-sm text-[var(--color-success-700)]">
{t('dashboard:new_dashboard.pending_purchases.all_clear')}
</p>
</div>
</div>
)}
</div>
);
}
export default PendingPurchasesBlock;

View File

@@ -0,0 +1,417 @@
/**
* ProductionStatusBlock - Block 4: "Estado de Produccion"
*
* Displays today's production overview:
* - Late to start batches (should have started but haven't)
* - Currently running batches (IN_PROGRESS)
* - Pending batches for today
* - AI reasoning for batch scheduling
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
Brain,
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
Factory,
Play,
Timer,
} from 'lucide-react';
interface ProductionStatusBlockProps {
lateToStartBatches?: any[];
runningBatches?: any[];
pendingBatches?: any[];
onStartBatch?: (batchId: string) => Promise<void>;
onViewBatch?: (batchId: string) => void;
loading?: boolean;
}
export function ProductionStatusBlock({
lateToStartBatches = [],
runningBatches = [],
pendingBatches = [],
onStartBatch,
onViewBatch,
loading,
}: ProductionStatusBlockProps) {
const { t } = useTranslation(['dashboard', 'common', 'production']);
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
const [processingId, setProcessingId] = useState<string | null>(null);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
</div>
</div>
<div className="space-y-3">
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
<div className="h-20 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
);
}
const hasLate = lateToStartBatches.length > 0;
const hasRunning = runningBatches.length > 0;
const hasPending = pendingBatches.length > 0;
const hasAnyProduction = hasLate || hasRunning || hasPending;
const totalCount = lateToStartBatches.length + runningBatches.length + pendingBatches.length;
// Determine header status
const status = hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
const statusStyles = {
success: {
iconBg: 'bg-[var(--color-success-100)]',
iconColor: 'text-[var(--color-success-600)]',
},
warning: {
iconBg: 'bg-[var(--color-warning-100)]',
iconColor: 'text-[var(--color-warning-600)]',
},
info: {
iconBg: 'bg-[var(--color-info-100)]',
iconColor: 'text-[var(--color-info-600)]',
},
error: {
iconBg: 'bg-[var(--color-error-100)]',
iconColor: 'text-[var(--color-error-600)]',
},
};
const styles = statusStyles[status];
// Handle start batch
const handleStartBatch = async (batchId: string) => {
if (!onStartBatch || processingId) return;
setProcessingId(batchId);
try {
await onStartBatch(batchId);
} finally {
setProcessingId(null);
}
};
// Toggle reasoning expansion
const toggleReasoning = (batchId: string) => {
setExpandedReasoningId(expandedReasoningId === batchId ? null : batchId);
};
// Format AI reasoning
const formatReasoning = (batch: any): string | null => {
const reasoningData = batch.reasoning_data;
if (!reasoningData) return null;
if (typeof reasoningData === 'string') return reasoningData;
if (reasoningData.type === 'forecast_demand') {
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', {
product: reasoningData.parameters?.product_name || batch.product_name,
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
});
}
if (reasoningData.type === 'customer_order') {
return t('dashboard:new_dashboard.production_status.reasoning.customer_order', {
customer: reasoningData.parameters?.customer_name || 'customer',
});
}
if (reasoningData.summary) return reasoningData.summary;
return null;
};
// Format time
const formatTime = (isoString: string | null | undefined) => {
if (!isoString) return '--:--';
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Calculate progress percentage for running batches
const calculateProgress = (batch: any): number => {
if (!batch.actual_start_time || !batch.planned_end_time) return 0;
const start = new Date(batch.actual_start_time).getTime();
const end = new Date(batch.planned_end_time).getTime();
const now = Date.now();
if (now >= end) return 100;
if (now <= start) return 0;
return Math.round(((now - start) / (end - start)) * 100);
};
// Render a batch item
const renderBatchItem = (batch: any, type: 'late' | 'running' | 'pending', index: number, total: number) => {
const batchId = batch.id || batch.batch_id;
const isProcessing = processingId === batchId;
const isExpanded = expandedReasoningId === batchId;
const reasoning = formatReasoning(batch);
const progress = type === 'running' ? calculateProgress(batch) : 0;
const typeStyles = {
late: {
timeBg: 'bg-[var(--color-error-100)]',
timeColor: 'text-[var(--color-error-700)]',
icon: <AlertTriangle className="w-4 h-4 text-[var(--color-error-600)]" />,
},
running: {
timeBg: 'bg-[var(--color-info-100)]',
timeColor: 'text-[var(--color-info-700)]',
icon: <Timer className="w-4 h-4 text-[var(--color-info-600)]" />,
},
pending: {
timeBg: 'bg-[var(--color-warning-100)]',
timeColor: 'text-[var(--color-warning-700)]',
icon: <Clock className="w-4 h-4 text-[var(--text-tertiary)]" />,
},
};
const batchStyles = typeStyles[type];
return (
<div
key={batchId || index}
className={`p-4 ${index < total - 1 ? 'border-b border-[var(--border-primary)]' : ''}`}
>
<div className="flex items-start gap-4">
{/* Batch Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{batchStyles.icon}
<span className="font-semibold text-[var(--text-primary)]">
{batch.product_name || 'Unknown Product'}
</span>
{reasoning && (
<button
onClick={() => toggleReasoning(batchId)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-[var(--color-primary-50)] text-[var(--color-primary-700)] hover:bg-[var(--color-primary-100)] transition-colors"
>
<Brain className="w-3 h-3" />
AI
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mb-2">
{t('dashboard:new_dashboard.production_status.batch_info', {
number: batch.batch_number || batchId?.slice(0, 8),
quantity: batch.planned_quantity || 0,
})}
</p>
{/* Time/Status Badge */}
<div className="flex items-center gap-2">
{type === 'late' && (
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.production_status.should_have_started', {
time: formatTime(batch.planned_start_time),
})}
</div>
)}
{type === 'running' && (
<>
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
<Timer className="w-3 h-3" />
{t('dashboard:new_dashboard.production_status.started_at', {
time: formatTime(batch.actual_start_time),
})}
</div>
{/* Progress Bar */}
<div className="flex-1 max-w-[120px]">
<div className="h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-info-500)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-[var(--text-tertiary)]">{progress}%</span>
</div>
</>
)}
{type === 'pending' && batch.planned_start_time && (
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded ${batchStyles.timeBg} ${batchStyles.timeColor} text-xs font-medium`}>
<Clock className="w-3 h-3" />
{t('dashboard:new_dashboard.production_status.starts_at', {
time: formatTime(batch.planned_start_time),
})}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{(type === 'late' || type === 'pending') && onStartBatch && (
<button
onClick={() => handleStartBatch(batchId)}
disabled={isProcessing}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-success-600)] text-white hover:bg-[var(--color-success-700)] transition-colors disabled:opacity-50"
>
<Play className="w-4 h-4" />
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.production_status.start_batch')}
</span>
</button>
)}
{type === 'running' && onViewBatch && (
<button
onClick={() => onViewBatch(batchId)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
<span className="text-sm font-medium">
{t('dashboard:new_dashboard.production_status.view_details')}
</span>
</button>
)}
</div>
</div>
{/* AI Reasoning (Expanded) */}
{isExpanded && reasoning && (
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
<div className="flex items-start gap-2">
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
{t('dashboard:new_dashboard.production_status.ai_reasoning')}
</p>
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
</div>
</div>
</div>
)}
</div>
);
};
return (
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-4">
{/* Icon */}
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${styles.iconBg}`}>
{hasLate ? (
<AlertTriangle className={`w-6 h-6 ${styles.iconColor}`} />
) : hasAnyProduction ? (
<Factory className={`w-6 h-6 ${styles.iconColor}`} />
) : (
<CheckCircle2 className={`w-6 h-6 ${styles.iconColor}`} />
)}
</div>
{/* Title & Count */}
<div className="flex-1">
<h2 className="text-xl font-bold text-[var(--text-primary)]">
{t('dashboard:new_dashboard.production_status.title')}
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{hasAnyProduction
? t('dashboard:new_dashboard.production_status.count', { count: totalCount })
: t('dashboard:new_dashboard.production_status.no_production')}
</p>
</div>
{/* Count Badges */}
<div className="flex items-center gap-2">
{hasLate && (
<div className="px-3 py-1 rounded-full bg-[var(--color-error-100)] text-[var(--color-error-700)] font-semibold text-sm">
{lateToStartBatches.length} {t('dashboard:new_dashboard.production_status.late_badge')}
</div>
)}
{hasRunning && (
<div className="px-3 py-1 rounded-full bg-[var(--color-info-100)] text-[var(--color-info-700)] font-semibold text-sm">
{runningBatches.length} {t('dashboard:new_dashboard.production_status.running_badge')}
</div>
)}
{hasPending && (
<div className="px-3 py-1 rounded-full bg-[var(--color-warning-100)] text-[var(--color-warning-700)] font-semibold text-sm">
{pendingBatches.length}
</div>
)}
</div>
</div>
</div>
{/* Content */}
{hasAnyProduction ? (
<div className="border-t border-[var(--border-primary)]">
{/* Late to Start Section */}
{hasLate && (
<div className="bg-[var(--color-error-50)]">
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dashboard:new_dashboard.production_status.late_section')}
</h3>
</div>
{lateToStartBatches.map((batch, index) =>
renderBatchItem(batch, 'late', index, lateToStartBatches.length)
)}
</div>
)}
{/* Running Section */}
{hasRunning && (
<div className="bg-[var(--color-info-50)]">
<div className="px-6 py-3 border-b border-[var(--color-info-100)]">
<h3 className="text-sm font-semibold text-[var(--color-info-700)] flex items-center gap-2">
<Timer className="w-4 h-4" />
{t('dashboard:new_dashboard.production_status.running_section')}
</h3>
</div>
{runningBatches.map((batch, index) =>
renderBatchItem(batch, 'running', index, runningBatches.length)
)}
</div>
)}
{/* Pending Section */}
{hasPending && (
<div>
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
<h3 className="text-sm font-semibold text-[var(--text-secondary)] flex items-center gap-2">
<Clock className="w-4 h-4" />
{t('dashboard:new_dashboard.production_status.pending_section')}
</h3>
</div>
{pendingBatches.map((batch, index) =>
renderBatchItem(batch, 'pending', index, pendingBatches.length)
)}
</div>
)}
</div>
) : (
/* Empty State */
<div className="px-6 pb-6">
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-success-50)] border border-[var(--color-success-100)]">
<CheckCircle2 className="w-6 h-6 text-[var(--color-success-600)]" />
<p className="text-sm text-[var(--color-success-700)]">
{t('dashboard:new_dashboard.production_status.all_clear')}
</p>
</div>
</div>
)}
</div>
);
}
export default ProductionStatusBlock;

View File

@@ -0,0 +1,265 @@
/**
* SystemStatusBlock - Block 1: "Estado del Sistema"
*
* Displays system status including:
* - Issues requiring user action
* - Issues prevented by AI
* - Last intelligent system run timestamp
* - AI handling rate and savings
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Activity,
AlertTriangle,
Bot,
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
Sparkles,
TrendingUp,
} from 'lucide-react';
import type { DashboardData, OrchestrationSummary } from '../../../api/hooks/useDashboardData';
interface SystemStatusBlockProps {
data: DashboardData | undefined;
loading?: boolean;
}
export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) {
const { t } = useTranslation(['dashboard', 'common']);
const [isExpanded, setIsExpanded] = useState(false);
if (loading) {
return (
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-[var(--bg-secondary)] rounded-full"></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/2"></div>
</div>
</div>
</div>
);
}
const issuesRequiringAction = data?.issuesRequiringAction || 0;
const issuesPreventedByAI = data?.issuesPreventedByAI || 0;
const orchestrationSummary = data?.orchestrationSummary;
const preventedIssues = data?.preventedIssues || [];
// Determine status: green if no issues, yellow/red if issues exist
const hasIssues = issuesRequiringAction > 0;
const status = hasIssues ? 'warning' : 'success';
// Format last run time
const formatLastRun = (timestamp: string | null | undefined) => {
if (!timestamp) return t('dashboard:new_dashboard.system_status.never_run');
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 1) return t('common:time.just_now', 'Just now');
if (diffMinutes < 60) return t('common:time.minutes_ago', '{{count}} min ago', { count: diffMinutes });
if (diffHours < 24) return t('common:time.hours_ago', '{{count}}h ago', { count: diffHours });
return date.toLocaleDateString();
};
// Status styling
const statusStyles = {
success: {
bg: 'bg-[var(--color-success-50)]',
border: 'border-[var(--color-success-200)]',
iconBg: 'bg-[var(--color-success-100)]',
iconColor: 'text-[var(--color-success-600)]',
},
warning: {
bg: 'bg-[var(--color-warning-50)]',
border: 'border-[var(--color-warning-200)]',
iconBg: 'bg-[var(--color-warning-100)]',
iconColor: 'text-[var(--color-warning-600)]',
},
};
const styles = statusStyles[status];
return (
<div className={`rounded-xl shadow-lg border ${styles.border} ${styles.bg} overflow-hidden`}>
{/* Main Content */}
<div className="p-6">
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className={`w-16 h-16 rounded-full ${styles.iconBg} flex items-center justify-center flex-shrink-0`}>
{hasIssues ? (
<AlertTriangle className={`w-8 h-8 ${styles.iconColor}`} />
) : (
<CheckCircle2 className={`w-8 h-8 ${styles.iconColor}`} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<h2 className="text-xl font-bold text-[var(--text-primary)] mb-1">
{t('dashboard:new_dashboard.system_status.title')}
</h2>
{/* Status Message */}
<p className="text-[var(--text-secondary)] mb-4">
{hasIssues
? t('dashboard:new_dashboard.system_status.issues_requiring_action', {
count: issuesRequiringAction,
})
: t('dashboard:new_dashboard.system_status.all_clear')}
</p>
{/* Stats Row */}
<div className="flex flex-wrap gap-4">
{/* Issues Requiring Action */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
<AlertTriangle
className={`w-5 h-5 ${
issuesRequiringAction > 0 ? 'text-[var(--color-warning-500)]' : 'text-[var(--text-tertiary)]'
}`}
/>
<span className="text-sm font-medium text-[var(--text-primary)]">
{issuesRequiringAction}
</span>
<span className="text-sm text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.action_needed_label')}
</span>
</div>
{/* Issues Prevented by AI */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
<Bot className="w-5 h-5 text-[var(--color-primary)]" />
<span className="text-sm font-medium text-[var(--text-primary)]">
{issuesPreventedByAI}
</span>
<span className="text-sm text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.ai_prevented_label')}
</span>
</div>
{/* Last Run */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--border-primary)]">
<Clock className="w-5 h-5 text-[var(--text-tertiary)]" />
<span className="text-sm text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.last_run_label')}:
</span>
<span className="text-sm font-medium text-[var(--text-primary)]">
{formatLastRun(orchestrationSummary?.runTimestamp)}
</span>
</div>
</div>
</div>
{/* Expand Button (if there are prevented issues to show) */}
{issuesPreventedByAI > 0 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-2 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-[var(--text-tertiary)]" />
) : (
<ChevronDown className="w-5 h-5 text-[var(--text-tertiary)]" />
)}
</button>
)}
</div>
</div>
{/* Expanded AI Details Section */}
{isExpanded && issuesPreventedByAI > 0 && (
<div className="border-t border-[var(--border-primary)] bg-[var(--bg-primary)] p-6">
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[var(--color-primary)]" />
{t('dashboard:new_dashboard.system_status.ai_prevented_details')}
</h3>
{/* AI Stats */}
{orchestrationSummary && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
{orchestrationSummary.aiHandlingRate !== undefined && (
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
<Activity className="w-4 h-4" />
{t('dashboard:new_dashboard.system_status.ai_handling_rate')}
</div>
<div className="text-xl font-bold text-[var(--color-primary)]">
{Math.round(orchestrationSummary.aiHandlingRate)}%
</div>
</div>
)}
{orchestrationSummary.estimatedSavingsEur !== undefined && orchestrationSummary.estimatedSavingsEur > 0 && (
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
<TrendingUp className="w-4 h-4" />
{t('dashboard:new_dashboard.system_status.estimated_savings')}
</div>
<div className="text-xl font-bold text-[var(--color-success-600)]">
{orchestrationSummary.estimatedSavingsEur.toLocaleString()}
</div>
</div>
)}
<div className="p-3 rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)] mb-1">
<Bot className="w-4 h-4" />
{t('dashboard:new_dashboard.system_status.issues_prevented')}
</div>
<div className="text-xl font-bold text-[var(--color-primary)]">
{issuesPreventedByAI}
</div>
</div>
</div>
)}
{/* Prevented Issues List */}
{preventedIssues.length > 0 && (
<div className="space-y-2">
{preventedIssues.slice(0, 5).map((issue: any, index: number) => (
<div
key={issue.id || index}
className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-primary)]"
>
<CheckCircle2 className="w-5 h-5 text-[var(--color-success-500)] flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{issue.title || issue.message || t('dashboard:new_dashboard.system_status.issue_prevented')}
</p>
{issue.business_impact?.financial_impact_eur && (
<p className="text-xs text-[var(--text-secondary)]">
{t('dashboard:new_dashboard.system_status.saved')}: {issue.business_impact.financial_impact_eur.toLocaleString()}
</p>
)}
</div>
</div>
))}
{preventedIssues.length > 5 && (
<p className="text-sm text-[var(--text-secondary)] text-center py-2">
{t('dashboard:new_dashboard.system_status.and_more', {
count: preventedIssues.length - 5,
})}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
export default SystemStatusBlock;

View File

@@ -0,0 +1,10 @@
/**
* Dashboard Blocks - Barrel Export
*
* Export all dashboard block components for the new Panel de Control design.
*/
export { SystemStatusBlock } from './SystemStatusBlock';
export { PendingPurchasesBlock } from './PendingPurchasesBlock';
export { PendingDeliveriesBlock } from './PendingDeliveriesBlock';
export { ProductionStatusBlock } from './ProductionStatusBlock';

View File

@@ -3,14 +3,16 @@
// ================================================================
/**
* Dashboard Components Export
* Barrel export for all JTBD dashboard components
* Barrel export for all dashboard components
*/
// Core Dashboard Components (JTBD-Aligned)
export { GlanceableHealthHero } from './GlanceableHealthHero';
export { UnifiedActionQueueCard } from './UnifiedActionQueueCard';
export { ExecutionProgressTracker } from './ExecutionProgressTracker';
export { IntelligentSystemSummaryCard } from './IntelligentSystemSummaryCard';
// New Dashboard Blocks (4 focused blocks for Panel de Control)
export {
SystemStatusBlock,
PendingPurchasesBlock,
PendingDeliveriesBlock,
ProductionStatusBlock,
} from './blocks';
// Setup Flow Components
export { SetupWizardBlocker } from './SetupWizardBlocker';

View File

@@ -223,23 +223,26 @@ export const ModifyPurchaseOrderModal: React.FC<ModifyPurchaseOrderModalProps> =
label: 'Unidad',
type: 'select',
required: true,
options: unitOptions
options: unitOptions,
disabled: true
},
{
name: 'unit_price',
label: 'Precio Unitario (€)',
type: 'currency',
required: true,
placeholder: '0.00'
placeholder: '0.00',
disabled: true
}
],
addButtonLabel: 'Agregar Producto',
emptyStateText: 'No hay productos en esta orden',
showSubtotals: true,
subtotalFields: { quantity: 'ordered_quantity', price: 'unit_price' },
disabled: false
disabled: true,
disableRemove: true
},
helpText: 'Modifica las cantidades, unidades y precios según sea necesario'
helpText: 'Solo puedes modificar las cantidades. Los precios unitarios están fijados por el proveedor seleccionado.'
}
]
};

View File

@@ -114,6 +114,7 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
};
const isDisabled = listConfig.disabled ?? false;
const disableRemove = listConfig.disableRemove ?? false;
return (
<div className="space-y-4">
@@ -148,13 +149,15 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
<div key={item.id || itemIndex} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--text-primary)]">Elemento #{itemIndex + 1}</span>
<button
type="button"
onClick={() => removeItem(itemIndex)}
className="p-1 text-red-500 hover:text-red-700 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
{!disableRemove && (
<button
type="button"
onClick={() => removeItem(itemIndex)}
className="p-1 text-red-500 hover:text-red-700 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -223,6 +226,7 @@ export interface AddModalField {
showSubtotals?: boolean; // For calculating item totals
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
disabled?: boolean; // Disable adding new items
disableRemove?: boolean; // Disable removing existing items
};
}

View File

@@ -171,14 +171,19 @@
"health": {
"production_on_schedule": "Production on schedule",
"production_delayed": "{count} production batch{count, plural, one {} other {es}} delayed",
"production_late_to_start": "{count} batch{count, plural, one {} other {es}} not started on time",
"production_delayed_and_late": "{delayed} batch{delayed, plural, one {} other {es}} delayed and {late} not started on time",
"production_issues": "{total} production issue{total, plural, one {} other {s}}",
"production_ai_prevented": "AI prevented {count} production delay{count, plural, one {} other {s}}",
"all_ingredients_in_stock": "All ingredients in 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",
"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",
"procurement_ai_prevented": "AI created {count} purchase order{count, plural, one {} other {s}} automatically",
"deliveries_on_track": "All deliveries on track",
"deliveries_overdue": "{count} deliver{count, plural, one {y} other {ies}} overdue",
"deliveries_ai_prevented": "AI prevented {count} delivery issue{count, plural, one {} other {s}}",
"deliveries_pending": "{count} pending deliver{count, plural, one {y} other {ies}}",
"all_systems_operational": "All systems operational",
"critical_issues": "{count} critical issue{count, plural, one {} other {s}}",
@@ -403,5 +408,76 @@
"delivered": "Delivered",
"failed": "Failed",
"distribution_routes": "Distribution Routes"
},
"new_dashboard": {
"system_status": {
"title": "System Status",
"issues_requiring_action": "{count, plural, one {# issue} other {# issues}} requiring your action",
"all_clear": "All systems running smoothly",
"never_run": "Never run",
"action_needed_label": "action needed",
"ai_prevented_label": "prevented by AI",
"last_run_label": "Last run",
"ai_prevented_details": "Issues Prevented by AI",
"ai_handling_rate": "AI Handling Rate",
"estimated_savings": "Estimated Savings",
"issues_prevented": "Issues Prevented",
"issue_prevented": "Issue prevented",
"saved": "Saved",
"and_more": "+{count} more"
},
"pending_purchases": {
"title": "Pending Purchases",
"count": "{count, plural, one {# order} other {# orders}} awaiting approval",
"no_pending": "No pending purchase orders",
"all_clear": "No purchase orders pending approval",
"po_number": "PO #{number}",
"supplier": "Supplier: {name}",
"approve": "Approve",
"reject": "Reject",
"view_details": "View Details",
"ai_reasoning": "AI created this PO because:",
"reasoning": {
"low_stock": "{ingredient} will run out in {days, plural, =0 {less than a day} one {# day} other {# days}}",
"low_stock_detailed": "{count, plural, one {# critical ingredient} other {# critical ingredients}} at risk: {products}. Earliest depletion in {days, plural, =0 {<1 day} one {1 day} other {# days}}, affecting {batches, plural, one {# batch} other {# batches}}. Potential loss: €{loss}",
"demand_forecast": "Demand for {product} is expected to increase by {increase}%"
}
},
"pending_deliveries": {
"title": "Pending Deliveries",
"count": "{count, plural, one {# delivery} other {# deliveries}} expected today",
"no_deliveries": "No deliveries expected today",
"all_clear": "No pending deliveries today",
"overdue_section": "Overdue Deliveries",
"today_section": "Expected Today",
"overdue_badge": "overdue",
"po_ref": "PO #{number}",
"overdue_by": "Overdue by {hours}",
"arriving_in": "Arriving in {hours}",
"call_supplier": "Call",
"mark_received": "Received"
},
"production_status": {
"title": "Production Status",
"count": "{count, plural, one {# batch} other {# batches}} today",
"no_production": "No production scheduled for today",
"all_clear": "No production scheduled for today",
"late_section": "Late to Start",
"running_section": "Currently Running",
"pending_section": "Pending Today",
"late_badge": "late",
"running_badge": "running",
"batch_info": "Batch #{number} - {quantity} units",
"should_have_started": "Should have started at {time}",
"started_at": "Started at {time}",
"starts_at": "Starts at {time}",
"start_batch": "Start",
"view_details": "View",
"ai_reasoning": "AI scheduled this batch because:",
"reasoning": {
"forecast_demand": "Predicted demand of {demand} units for {product}",
"customer_order": "Customer order from {customer}"
}
}
}
}

View File

@@ -198,14 +198,19 @@
"health": {
"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_late_to_start": "{count} lote{count, plural, one {} other {s}} no iniciado{count, plural, one {} other {s}} a tiempo",
"production_delayed_and_late": "{delayed} lote{delayed, plural, one {} other {s}} retrasado{delayed, plural, one {} other {s}} y {late} no iniciado{late, plural, one {} other {s}}",
"production_issues": "{total} problema{total, plural, one {} other {s}} de producción",
"production_ai_prevented": "IA evitó {count} retraso{count, plural, one {} other {s}} de producción",
"all_ingredients_in_stock": "Todos los ingredientes en 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",
"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",
"procurement_ai_prevented": "IA creó {count} orden{count, plural, one {} other {es}} de compra automáticamente",
"deliveries_on_track": "Todas las entregas a tiempo",
"deliveries_overdue": "{count} entrega{count, plural, one {} other {s}} atrasada{count, plural, one {} other {s}}",
"deliveries_ai_prevented": "IA evitó {count} problema{count, plural, one {} other {s}} de entrega",
"deliveries_pending": "{count} entrega{count, plural, one {} other {s}} pendiente{count, plural, one {} other {s}}",
"all_systems_operational": "Todos los sistemas operativos",
"critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}",
@@ -452,5 +457,76 @@
"delivered": "Entregada",
"failed": "Fallida",
"distribution_routes": "Rutas de Distribución"
},
"new_dashboard": {
"system_status": {
"title": "Estado del Sistema",
"issues_requiring_action": "{count, plural, one {# problema requiere} other {# problemas requieren}} tu acción",
"all_clear": "Todos los sistemas funcionan correctamente",
"never_run": "Nunca ejecutado",
"action_needed_label": "acción requerida",
"ai_prevented_label": "evitados por IA",
"last_run_label": "Última ejecución",
"ai_prevented_details": "Problemas Evitados por IA",
"ai_handling_rate": "Tasa de Gestión IA",
"estimated_savings": "Ahorros Estimados",
"issues_prevented": "Problemas Evitados",
"issue_prevented": "Problema evitado",
"saved": "Ahorrado",
"and_more": "+{count} más"
},
"pending_purchases": {
"title": "Compras Pendientes",
"count": "{count, plural, one {# orden} other {# órdenes}} esperando aprobación",
"no_pending": "Sin órdenes de compra pendientes",
"all_clear": "Sin órdenes de compra pendientes de aprobación",
"po_number": "OC #{number}",
"supplier": "Proveedor: {name}",
"approve": "Aprobar",
"reject": "Rechazar",
"view_details": "Ver Detalles",
"ai_reasoning": "IA creó esta OC porque:",
"reasoning": {
"low_stock": "{ingredient} se agotará en {days, plural, =0 {menos de un día} one {# día} other {# días}}",
"low_stock_detailed": "{count, plural, one {# ingrediente crítico} other {# ingredientes críticos}} en riesgo: {products}. Agotamiento más temprano en {days, plural, =0 {<1 día} one {1 día} other {# días}}, afectando {batches, plural, one {# lote} other {# lotes}}. Pérdida potencial: €{loss}",
"demand_forecast": "Se espera que la demanda de {product} aumente un {increase}%"
}
},
"pending_deliveries": {
"title": "Entregas Pendientes",
"count": "{count, plural, one {# entrega} other {# entregas}} esperadas hoy",
"no_deliveries": "Sin entregas esperadas hoy",
"all_clear": "Sin entregas pendientes hoy",
"overdue_section": "Entregas Atrasadas",
"today_section": "Esperadas Hoy",
"overdue_badge": "atrasada",
"po_ref": "OC #{number}",
"overdue_by": "Atrasada {hours}",
"arriving_in": "Llega en {hours}",
"call_supplier": "Llamar",
"mark_received": "Recibido"
},
"production_status": {
"title": "Estado de Producción",
"count": "{count, plural, one {# lote} other {# lotes}} hoy",
"no_production": "Sin producción programada para hoy",
"all_clear": "Sin producción programada para hoy",
"late_section": "Atrasados para Empezar",
"running_section": "En Ejecución",
"pending_section": "Pendientes Hoy",
"late_badge": "atrasado",
"running_badge": "en curso",
"batch_info": "Lote #{number} - {quantity} unidades",
"should_have_started": "Debía empezar a las {time}",
"started_at": "Empezó a las {time}",
"starts_at": "Empieza a las {time}",
"start_batch": "Iniciar",
"view_details": "Ver",
"ai_reasoning": "IA programó este lote porque:",
"reasoning": {
"forecast_demand": "Demanda prevista de {demand} unidades para {product}",
"customer_order": "Pedido del cliente {customer}"
}
}
}
}

View File

@@ -395,5 +395,76 @@
"delivered": "Entregatua",
"failed": "Huts egin du",
"distribution_routes": "Banaketa Ibilbideak"
},
"new_dashboard": {
"system_status": {
"title": "Sistema Egoera",
"issues_requiring_action": "{count, plural, one {# arazok} other {# arazok}} zure ekintza behar {count, plural, one {du} other {dute}}",
"all_clear": "Sistema guztiak ondo dabiltza",
"never_run": "Inoiz exekutatu gabe",
"action_needed_label": "ekintza behar",
"ai_prevented_label": "IAk saihestua",
"last_run_label": "Azken exekuzioa",
"ai_prevented_details": "IAk Saihestutako Arazoak",
"ai_handling_rate": "IA Kudeaketa Tasa",
"estimated_savings": "Aurrezki Estimatuak",
"issues_prevented": "Saihestutako Arazoak",
"issue_prevented": "Arazo saihestua",
"saved": "Aurreztua",
"and_more": "+{count} gehiago"
},
"pending_purchases": {
"title": "Erosketa Zain",
"count": "{count, plural, one {# agindu} other {# agindu}} onarpenaren zai",
"no_pending": "Ez dago erosketa-agindu zain",
"all_clear": "Ez dago erosketa-agindu onartzeko zain",
"po_number": "EA #{number}",
"supplier": "Hornitzailea: {name}",
"approve": "Onartu",
"reject": "Baztertu",
"view_details": "Xehetasunak Ikusi",
"ai_reasoning": "IAk EA hau sortu zuen zeren:",
"reasoning": {
"low_stock": "{ingredient} {days, plural, =0 {egun bat baino gutxiago} one {# egunean} other {# egunetan}} agortuko da",
"low_stock_detailed": "{count, plural, one {# osagai kritiko} other {# osagai kritiko}} arriskuan: {products}. Lehen agortze {days, plural, =0 {<1 egun} one {1 egun} other {# egun}}, {batches, plural, one {# lote} other {# lote}} ukituz. Galera potentziala: €{loss}",
"demand_forecast": "{product} produktuaren eskaria %{increase} igotzea espero da"
}
},
"pending_deliveries": {
"title": "Entrega Zain",
"count": "{count, plural, one {# entrega} other {# entrega}} gaur espero",
"no_deliveries": "Ez dago entregarik gaur esperatzen",
"all_clear": "Ez dago entregarik zain gaur",
"overdue_section": "Atzeratutako Entregak",
"today_section": "Gaur Esperatzen",
"overdue_badge": "atzeratua",
"po_ref": "EA #{number}",
"overdue_by": "{hours} atzeratuta",
"arriving_in": "{hours} barru iristen",
"call_supplier": "Deitu",
"mark_received": "Jasota"
},
"production_status": {
"title": "Ekoizpen Egoera",
"count": "{count, plural, one {# lote} other {# lote}} gaur",
"no_production": "Ez dago ekoizpenik programatuta gaur",
"all_clear": "Ez dago ekoizpenik programatuta gaur",
"late_section": "Hasteko Atzeratua",
"running_section": "Martxan",
"pending_section": "Gaur Zain",
"late_badge": "atzeratua",
"running_badge": "martxan",
"batch_info": "Lote #{number} - {quantity} unitate",
"should_have_started": "{time}-an hasi behar zen",
"started_at": "{time}-an hasi zen",
"starts_at": "{time}-an hasiko da",
"start_batch": "Hasi",
"view_details": "Ikusi",
"ai_reasoning": "IAk lote hau programatu zuen zeren:",
"reasoning": {
"forecast_demand": "{product}-rentzat {demand} unitateko eskaria aurreikusita",
"customer_order": "{customer} bezeroaren eskaera"
}
}
}
}

View File

@@ -15,45 +15,33 @@
* - Trust-building (explain system reasoning)
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RefreshCw, ExternalLink, Plus, Sparkles, Wifi, WifiOff } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Plus, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTenant } from '../../stores/tenant.store';
import {
useBakeryHealthStatus,
useOrchestrationSummary,
useUnifiedActionQueue,
useProductionTimeline,
useApprovePurchaseOrder,
useStartProductionBatch,
usePauseProductionBatch,
useExecutionProgress,
useDashboardRealtime, // PHASE 3: SSE state sync
useProgressiveDashboard, // PHASE 4: Progressive loading
} from '../../api/hooks/useProfessionalDashboard';
import { useDashboardData, useDashboardRealtimeSync } from '../../api/hooks/useDashboardData';
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
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 { GlanceableHealthHero } from '../../components/dashboard/GlanceableHealthHero';
import { SetupWizardBlocker } from '../../components/dashboard/SetupWizardBlocker';
import { CollapsibleSetupBanner } from '../../components/dashboard/CollapsibleSetupBanner';
import { UnifiedActionQueueCard } from '../../components/dashboard/UnifiedActionQueueCard';
import { ExecutionProgressTracker } from '../../components/dashboard/ExecutionProgressTracker';
import { IntelligentSystemSummaryCard } from '../../components/dashboard/IntelligentSystemSummaryCard';
import { useAuthUser } from '../../stores';
import {
SystemStatusBlock,
PendingPurchasesBlock,
PendingDeliveriesBlock,
ProductionStatusBlock,
} from '../../components/dashboard/blocks';
import { UnifiedPurchaseOrderModal } from '../../components/domain/procurement/UnifiedPurchaseOrderModal';
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
import type { ItemType } from '../../components/domain/unified-wizard';
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
import { Package, Users, BookOpen, Shield } from 'lucide-react';
import {
useBatchNotifications,
useDeliveryNotifications,
useOrchestrationNotifications,
} from '../../hooks';
// Import Enterprise Dashboard
@@ -63,28 +51,21 @@ import { SUBSCRIPTION_TIERS } from '../../api/types/subscription';
// Rename the existing component to BakeryDashboard
export function BakeryDashboard() {
const navigate = useNavigate();
const { t } = useTranslation(['dashboard', 'common', 'alerts']);
const { currentTenant } = useTenant();
const tenantId = currentTenant?.id || '';
const { startTour } = useDemoTour();
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
// Unified Add Wizard state
const [isAddWizardOpen, setIsAddWizardOpen] = useState(false);
const [addWizardError, setAddWizardError] = useState<string | null>(null);
// PO Details Modal state
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
const [isPOModalOpen, setIsPOModalOpen] = useState(false);
const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view');
// Setup Progress Data
// Always fetch setup data to determine true progress, but use localStorage as fallback during loading
// PHASE 1 OPTIMIZATION: Only use cached value if we're still waiting for API to respond
// Setup Progress Data - use localStorage as fallback during loading
const setupProgressFromStorage = useMemo(() => {
try {
const cached = localStorage.getItem(`setup_progress_${tenantId}`);
@@ -94,7 +75,7 @@ export function BakeryDashboard() {
}
}, [tenantId]);
// Always fetch the actual data to determine true progress
// Fetch setup data to determine true progress
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(
tenantId,
{},
@@ -117,296 +98,57 @@ export function BakeryDashboard() {
);
const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : [];
// PHASE 4: Progressive data loading for perceived performance boost
// NEW: Single unified data fetch for all 4 dashboard blocks
const {
health: {
data: healthStatus,
isLoading: healthLoading,
refetch: refetchHealth,
},
actionQueue: {
data: actionQueue,
isLoading: actionQueueLoading,
refetch: refetchActionQueue,
},
progress: {
data: executionProgress,
isLoading: executionProgressLoading,
refetch: refetchExecutionProgress,
},
overallLoading,
isReady,
} = useProgressiveDashboard(tenantId);
data: dashboardData,
isLoading: dashboardLoading,
refetch: refetchDashboard,
} = useDashboardData(tenantId);
// Additional hooks not part of progressive loading
const {
data: orchestrationSummary,
isLoading: orchestrationLoading,
refetch: refetchOrchestration,
} = useOrchestrationSummary(tenantId);
const {
data: productionTimeline,
isLoading: timelineLoading,
refetch: refetchTimeline,
} = useProductionTimeline(tenantId);
// Insights functionality removed as it's not needed with new architecture
const insights = undefined;
const insightsLoading = false;
const refetchInsights = () => {};
// PHASE 3: Enable SSE real-time state synchronization
useDashboardRealtime(tenantId);
// PHASE 6: Performance monitoring
useEffect(() => {
const loadTime = performance.now();
console.log(`📊 [Performance] Dashboard loaded in ${loadTime.toFixed(0)}ms`);
// Calculate setup completion status based on stored progress (approximation since actual data may not be loaded yet)
const setupComplete = setupProgressFromStorage >= 100;
if (loadTime > 1000) {
console.warn('⚠️ [Performance] Dashboard load time exceeded target (>1000ms):', {
loadTime: `${loadTime.toFixed(0)}ms`,
target: '1000ms',
setupComplete,
queriesSkipped: setupComplete ? 4 : 0,
});
} else {
console.log('✅ [Performance] Dashboard load time within target:', {
loadTime: `${loadTime.toFixed(0)}ms`,
target: '<1000ms',
setupComplete,
queriesSkipped: setupComplete ? 4 : 0,
});
}
}, [setupProgressFromStorage]); // Include setupProgressFromStorage as dependency
// Real-time event subscriptions for automatic refetching
const { notifications: batchNotifications } = useBatchNotifications();
const { notifications: deliveryNotifications } = useDeliveryNotifications();
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
const sseConnected = true; // Simplified - based on other notification hooks
// Store refetch callbacks in a ref to prevent infinite loop from dependency changes
// React Query refetch functions are recreated on every query state change, which would
// trigger useEffect again if they were in the dependency array
const refetchCallbacksRef = useRef({
refetchActionQueue,
refetchHealth,
refetchExecutionProgress,
refetchOrchestration,
});
// Store previous notification IDs to prevent infinite refetch loops
const prevBatchNotificationsRef = useRef('');
const prevDeliveryNotificationsRef = useRef('');
const prevOrchestrationNotificationsRef = useRef('');
// Update ref with latest callbacks on every render
useEffect(() => {
refetchCallbacksRef.current = {
refetchActionQueue,
refetchHealth,
refetchExecutionProgress,
refetchOrchestration,
};
});
// Track the latest notification ID to prevent re-running on same notification
// Use stringified ID array to create stable dependency that only changes when IDs actually change
const batchIdsString = JSON.stringify(batchNotifications.map(n => n.id));
const deliveryIdsString = JSON.stringify(deliveryNotifications.map(n => n.id));
const orchestrationIdsString = JSON.stringify(orchestrationNotifications.map(n => n.id));
console.log('📝 [Dashboard] Stringified ID arrays:', {
batchIdsString,
deliveryIdsString,
orchestrationIdsString,
});
const latestBatchNotificationId = useMemo(() => {
const result = batchNotifications.length === 0 ? '' : (batchNotifications[0]?.id || '');
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(() => {
console.log('⚡ [Dashboard] batchNotifications useEffect triggered', {
latestBatchNotificationId,
prevValue: prevBatchNotificationsRef.current,
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];
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.refetchHealth();
} else {
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
}
}
}, [latestBatchNotificationId]); // Only run when a NEW notification arrives
useEffect(() => {
console.log('⚡ [Dashboard] deliveryNotifications useEffect triggered', {
latestDeliveryNotificationId,
prevValue: prevDeliveryNotificationsRef.current,
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];
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.refetchHealth();
} else {
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
}
}
}, [latestDeliveryNotificationId]); // Only run when a NEW notification arrives
useEffect(() => {
console.log('⚡ [Dashboard] orchestrationNotifications useEffect triggered', {
latestOrchestrationNotificationId,
prevValue: prevOrchestrationNotificationsRef.current,
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];
if (latest.event_type === 'orchestration_run_completed') {
console.log('🚀 [Dashboard] Triggering refetch for orchestration event:', latest.event_type);
refetchCallbacksRef.current.refetchOrchestration();
refetchCallbacksRef.current.refetchActionQueue();
} else {
console.log('⏭️ [Dashboard] Skipping refetch - event type not relevant:', latest.event_type);
}
}
}, [latestOrchestrationNotificationId]); // Only run when a NEW notification arrives
// Enable SSE real-time state synchronization
useDashboardRealtimeSync(tenantId);
// Mutations
const approvePO = useApprovePurchaseOrder();
const rejectPO = useRejectPurchaseOrder();
const startBatch = useStartProductionBatch();
const pauseBatch = usePauseProductionBatch();
// Handlers
const handleApprove = async (actionId: string) => {
const handleApprove = async (poId: string) => {
try {
await approvePO.mutateAsync({ tenantId, poId: actionId });
// Refetch to update UI
refetchActionQueue();
refetchHealth();
await approvePO.mutateAsync({ tenantId, poId });
// SSE will handle refetch, but trigger immediate refetch for responsiveness
refetchDashboard();
} catch (error) {
console.error('Error approving PO:', error);
}
};
const handleReject = async (actionId: string, reason: string) => {
const handleReject = async (poId: string, reason: string) => {
try {
await rejectPO.mutateAsync({ tenantId, poId: actionId, reason });
// Refetch to update UI
refetchActionQueue();
refetchHealth();
await rejectPO.mutateAsync({ tenantId, poId, reason });
refetchDashboard();
} catch (error) {
console.error('Error rejecting PO:', error);
}
};
const handleViewDetails = (actionId: string) => {
const handleViewDetails = (poId: string) => {
// Open modal to show PO details in view mode
setSelectedPOId(actionId);
setSelectedPOId(poId);
setPOModalMode('view');
setIsPOModalOpen(true);
};
const handleModify = (actionId: string) => {
// Open modal to edit PO details
setSelectedPOId(actionId);
setPOModalMode('edit');
setIsPOModalOpen(true);
};
const handleStartBatch = async (batchId: string) => {
try {
await startBatch.mutateAsync({ tenantId, batchId });
refetchTimeline();
refetchHealth();
refetchDashboard();
} catch (error) {
console.error('Error starting batch:', error);
}
};
const handlePauseBatch = async (batchId: string) => {
try {
await pauseBatch.mutateAsync({ tenantId, batchId });
refetchTimeline();
refetchHealth();
} catch (error) {
console.error('Error pausing batch:', error);
}
};
// Calculate configuration sections for setup flow
const setupSections = useMemo(() => {
// Create safe fallbacks for icons to prevent React error #310
@@ -514,19 +256,11 @@ export function BakeryDashboard() {
};
}, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]);
const handleRefreshAll = () => {
refetchHealth();
refetchOrchestration();
refetchActionQueue();
refetchExecutionProgress();
refetchTimeline();
refetchInsights();
};
const handleAddWizardComplete = (itemType: ItemType, data?: any) => {
console.log('Item created:', itemType, data);
// Refetch relevant data based on what was added
handleRefreshAll();
// SSE events will handle most updates automatically, but we refetch here
// to ensure immediate feedback after user actions
refetchDashboard();
};
// Keyboard shortcut for Quick Add (Cmd/Ctrl + K)
@@ -600,14 +334,6 @@ export function BakeryDashboard() {
{/* Action Buttons */}
<div className="flex items-center gap-3">
<button
onClick={handleRefreshAll}
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)]"
>
<RefreshCw className="w-5 h-5" />
<span className="hidden sm:inline">{t('common:actions.refresh')}</span>
</button>
{/* Unified Add Button with Keyboard Shortcut */}
<button
onClick={() => setIsAddWizardOpen(true)}
@@ -632,8 +358,8 @@ export function BakeryDashboard() {
</div>
{/* Setup Flow - Three States */}
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality || !isReady ? (
/* Loading state - only show spinner until first priority data (health) is ready */
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality ? (
/* Loading state - only show spinner until setup data is ready */
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
</div>
@@ -653,103 +379,45 @@ export function BakeryDashboard() {
/>
)}
{/* Main Dashboard Layout */}
{/* Main Dashboard Layout - 4 New Focused Blocks */}
<div className="space-y-6">
{/* SECTION 1: Glanceable Health Hero (Traffic Light) - PRIORITY 1 */}
{/* BLOCK 1: System Status + AI Summary */}
<div data-tour="dashboard-stats">
{healthLoading ? (
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
</div>
) : (
<GlanceableHealthHero
healthStatus={healthStatus!}
loading={false}
urgentActionCount={actionQueue?.urgentCount || 0}
/>
)}
</div>
{/* SECTION 2: What Needs Your Attention (Unified Action Queue) - PRIORITY 2 */}
<div data-tour="pending-po-approvals">
{actionQueueLoading ? (
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
<div className="space-y-4">
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/3"></div>
<div className="h-32 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
) : (
<UnifiedActionQueueCard
actionQueue={actionQueue!}
loading={false}
tenantId={tenantId}
/>
)}
</div>
{/* SECTION 3: Execution Progress Tracker (Plan vs Actual) - PRIORITY 3 */}
<div data-tour="execution-progress">
{executionProgressLoading ? (
<div className="bg-[var(--bg-primary)] rounded-lg border border-[var(--border-primary)] p-6 animate-pulse">
<div className="space-y-4">
<div className="h-8 bg-[var(--bg-secondary)] rounded w-1/2"></div>
<div className="h-24 bg-[var(--bg-secondary)] rounded"></div>
</div>
</div>
) : (
<ExecutionProgressTracker
progress={executionProgress}
loading={false}
/>
)}
</div>
{/* SECTION 4: Intelligent System Summary - Unified AI Impact & Orchestration */}
<div data-tour="intelligent-system-summary">
<IntelligentSystemSummaryCard
orchestrationSummary={orchestrationSummary!}
orchestrationLoading={orchestrationLoading}
onWorkflowComplete={handleRefreshAll}
<SystemStatusBlock
data={dashboardData}
loading={dashboardLoading}
/>
</div>
{/* SECTION 6: Quick Action Links */}
<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 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">
<button
onClick={() => navigate('/app/operations/procurement')}
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)]"
>
<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 text-[var(--color-info)]" />
</button>
{/* BLOCK 2: Pending Purchases (PO Approvals) */}
<div data-tour="pending-po-approvals">
<PendingPurchasesBlock
pendingPOs={dashboardData?.pendingPOs || []}
loading={dashboardLoading}
onApprove={handleApprove}
onReject={handleReject}
onViewDetails={handleViewDetails}
/>
</div>
<button
onClick={() => navigate('/app/operations/production')}
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)]"
>
<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 text-[var(--color-success)]" />
</button>
{/* BLOCK 3: Pending Deliveries (Overdue + Today) */}
<div data-tour="pending-deliveries">
<PendingDeliveriesBlock
overdueDeliveries={dashboardData?.overdueDeliveries || []}
pendingDeliveries={dashboardData?.pendingDeliveries || []}
loading={dashboardLoading}
/>
</div>
<button
onClick={() => navigate('/app/database/inventory')}
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)]"
>
<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 text-[var(--color-secondary)]" />
</button>
<button
onClick={() => navigate('/app/database/suppliers')}
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)]"
>
<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 text-[var(--color-warning)]" />
</button>
</div>
{/* BLOCK 4: Production Status (Late/Running/Pending) */}
<div data-tour="execution-progress">
<ProductionStatusBlock
lateToStartBatches={dashboardData?.lateToStartBatches || []}
runningBatches={dashboardData?.runningBatches || []}
pendingBatches={dashboardData?.pendingBatches || []}
loading={dashboardLoading}
onStartBatch={handleStartBatch}
/>
</div>
</div>
</>
@@ -777,7 +445,8 @@ export function BakeryDashboard() {
setIsPOModalOpen(false);
setSelectedPOId(null);
setPOModalMode('view');
handleRefreshAll();
// SSE events will handle most updates automatically
refetchDashboard();
}}
onApprove={handleApprove}
onReject={handleReject}