Fix and UI imporvements 3
This commit is contained in:
317
frontend/src/api/hooks/useDashboardData.ts
Normal file
317
frontend/src/api/hooks/useDashboardData.ts
Normal 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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
265
frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx
Normal file
265
frontend/src/components/dashboard/blocks/SystemStatusBlock.tsx
Normal 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;
|
||||
10
frontend/src/components/dashboard/blocks/index.ts
Normal file
10
frontend/src/components/dashboard/blocks/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user