New alert system and panel de control page

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

View File

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

View File

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

View File

@@ -123,6 +123,15 @@ export function NewDashboardPage() {
const { notifications: deliveryNotifications } = useDeliveryNotifications();
const { 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
@@ -152,56 +161,118 @@ export function NewDashboardPage() {
});
// Track the latest notification ID to prevent re-running on same notification
const latestBatchNotificationId = useMemo(() =>
batchNotifications.length > 0 ? batchNotifications[0]?.id : null,
[batchNotifications]
);
// 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));
const latestDeliveryNotificationId = useMemo(() =>
deliveryNotifications.length > 0 ? deliveryNotifications[0]?.id : null,
[deliveryNotifications]
);
console.log('📝 [Dashboard] Stringified ID arrays:', {
batchIdsString,
deliveryIdsString,
orchestrationIdsString,
});
const latestOrchestrationNotificationId = useMemo(() =>
orchestrationNotifications.length > 0 ? orchestrationNotifications[0]?.id : null,
[orchestrationNotifications]
);
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(() => {
const currentBatchNotificationId = latestBatchNotificationId || '';
if (currentBatchNotificationId &&
currentBatchNotificationId !== prevBatchNotificationsRef.current) {
prevBatchNotificationsRef.current = currentBatchNotificationId;
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(() => {
const currentDeliveryNotificationId = latestDeliveryNotificationId || '';
if (currentDeliveryNotificationId &&
currentDeliveryNotificationId !== prevDeliveryNotificationsRef.current) {
prevDeliveryNotificationsRef.current = currentDeliveryNotificationId;
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(() => {
const currentOrchestrationNotificationId = latestOrchestrationNotificationId || '';
if (currentOrchestrationNotificationId &&
currentOrchestrationNotificationId !== prevOrchestrationNotificationsRef.current) {
prevOrchestrationNotificationsRef.current = currentOrchestrationNotificationId;
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
@@ -432,27 +503,21 @@ export function NewDashboardPage() {
// Note: startTour removed from deps to prevent infinite loop - the effect guards with sessionStorage ensure it only runs once
return (
<div className="min-h-screen pb-20 md:pb-8">
<div className="min-h-screen pb-20 md:pb-8 bg-[var(--bg-primary)]">
{/* Mobile-optimized container */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl md:text-4xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('dashboard:title')}</h1>
<p className="mt-1" style={{ color: 'var(--text-secondary)' }}>{t('dashboard:subtitle')}</p>
<h1 className="text-3xl md:text-4xl font-bold text-[var(--text-primary)]">{t('dashboard:title')}</h1>
<p className="mt-1 text-[var(--text-secondary)]">{t('dashboard:subtitle')}</p>
</div>
{/* 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"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
border: '1px solid',
color: 'var(--text-secondary)'
}}
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>
@@ -461,23 +526,19 @@ export function NewDashboardPage() {
{/* Unified Add Button with Keyboard Shortcut */}
<button
onClick={() => setIsAddWizardOpen(true)}
className="group relative flex items-center gap-2 px-6 py-2.5 rounded-lg font-semibold transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0"
style={{
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%)',
color: 'white'
}}
className="group relative flex items-center gap-2 px-6 py-2.5 rounded-lg font-semibold transition-all duration-200 shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white"
title={`Quick Add (${navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+K)`}
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">{t('common:actions.add')}</span>
<Sparkles className="w-4 h-4 opacity-80" />
{/* Keyboard shortcut badge - shown on hover */}
<span className="hidden lg:flex absolute -bottom-8 left-1/2 -translate-x-1/2 items-center gap-1 px-2 py-1 rounded text-xs font-mono opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap pointer-events-none" style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-secondary)', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<kbd className="px-1.5 py-0.5 rounded text-xs font-semibold" style={{ backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-secondary)' }}>
<span className="hidden lg:flex absolute -bottom-8 left-1/2 -translate-x-1/2 items-center gap-1 px-2 py-1 rounded text-xs font-mono opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap pointer-events-none bg-[var(--bg-primary)] text-[var(--text-secondary)] shadow-sm">
<kbd className="px-1.5 py-0.5 rounded text-xs font-semibold bg-[var(--bg-tertiary)] border border-[var(--border-secondary)]">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}
</kbd>
<span>+</span>
<kbd className="px-1.5 py-0.5 rounded text-xs font-semibold" style={{ backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-secondary)' }}>
<kbd className="px-1.5 py-0.5 rounded text-xs font-semibold bg-[var(--bg-tertiary)] border border-[var(--border-secondary)]">
K
</kbd>
</span>
@@ -545,43 +606,39 @@ export function NewDashboardPage() {
</div>
{/* SECTION 6: Quick Action Links */}
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>{t('dashboard:sections.quick_actions')}</h2>
<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"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-info)' }}
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" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_orders')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-info)' }} />
<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>
<button
onClick={() => navigate('/app/operations/production')}
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-success)' }}
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" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_production')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-success)' }} />
<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>
<button
onClick={() => navigate('/app/database/inventory')}
className="flex items-center justify-between p-4 rounded-lg transition-colors duration-200 group"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-secondary)' }}
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" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_inventory')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-secondary)' }} />
<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"
style={{ backgroundColor: 'var(--bg-tertiary)', borderLeft: '4px solid var(--color-warning)' }}
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" style={{ color: 'var(--text-primary)' }}>{t('dashboard:quick_actions.view_suppliers')}</span>
<ExternalLink className="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" style={{ color: 'var(--color-warning)' }} />
<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>
</div>

View File

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