Files
bakery-ia/frontend/src/pages/app/DashboardPage.tsx
2025-12-10 11:23:53 +01:00

487 lines
19 KiB
TypeScript

// ================================================================
// frontend/src/pages/app/DashboardPage.tsx
// ================================================================
/**
* JTBD-Aligned Dashboard Page
*
* Complete redesign based on Jobs To Be Done methodology.
* Focused on answering: "What requires my attention right now?"
*
* Key principles:
* - Automation-first (show what system did)
* - Action-oriented (prioritize tasks)
* - Progressive disclosure (show 20% that matters 80%)
* - Mobile-first (one-handed operation)
* - Trust-building (explain system reasoning)
*/
import { useState, useEffect, useMemo } from 'react';
import { Plus, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTenant } from '../../stores/tenant.store';
import {
useApprovePurchaseOrder,
useStartProductionBatch,
} 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 { SetupWizardBlocker } from '../../components/dashboard/SetupWizardBlocker';
import { CollapsibleSetupBanner } from '../../components/dashboard/CollapsibleSetupBanner';
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 Enterprise Dashboard
import EnterpriseDashboardPage from './EnterpriseDashboardPage';
import { useSubscription } from '../../api/hooks/subscription';
import { SUBSCRIPTION_TIERS } from '../../api/types/subscription';
// Rename the existing component to BakeryDashboard
export function BakeryDashboard() {
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);
// 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 - use localStorage as fallback during loading
const setupProgressFromStorage = useMemo(() => {
try {
const cached = localStorage.getItem(`setup_progress_${tenantId}`);
return cached ? parseInt(cached, 10) : 0;
} catch {
return 0;
}
}, [tenantId]);
// Fetch setup data to determine true progress
const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients(
tenantId,
{},
{ enabled: !!tenantId }
);
const { data: suppliers = [], isLoading: loadingSuppliers } = useSuppliers(
tenantId,
{},
{ enabled: !!tenantId }
);
const { data: recipes = [], isLoading: loadingRecipes } = useRecipes(
tenantId,
{},
{ enabled: !!tenantId }
);
const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates(
tenantId,
{},
{ enabled: !!tenantId }
);
const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : [];
// NEW: Single unified data fetch for all 4 dashboard blocks
const {
data: dashboardData,
isLoading: dashboardLoading,
refetch: refetchDashboard,
} = useDashboardData(tenantId);
// Enable SSE real-time state synchronization
useDashboardRealtimeSync(tenantId);
// Mutations
const approvePO = useApprovePurchaseOrder();
const rejectPO = useRejectPurchaseOrder();
const startBatch = useStartProductionBatch();
// Handlers
const handleApprove = async (poId: string) => {
try {
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 (poId: string, reason: string) => {
try {
await rejectPO.mutateAsync({ tenantId, poId, reason });
refetchDashboard();
} catch (error) {
console.error('Error rejecting PO:', error);
}
};
const handleViewDetails = (poId: string) => {
// Open modal to show PO details in view mode
setSelectedPOId(poId);
setPOModalMode('view');
setIsPOModalOpen(true);
};
const handleStartBatch = async (batchId: string) => {
try {
await startBatch.mutateAsync({ tenantId, batchId });
refetchDashboard();
} catch (error) {
console.error('Error starting batch:', error);
}
};
// Calculate configuration sections for setup flow
const setupSections = useMemo(() => {
// Create safe fallbacks for icons to prevent React error #310
const SafePackageIcon = Package;
const SafeUsersIcon = Users;
const SafeBookOpenIcon = BookOpen;
const SafeShieldIcon = Shield;
// Validate that all icons are properly imported before using them
const sections = [
{
id: 'inventory',
title: t('dashboard:config.inventory', 'Inventory'),
icon: SafePackageIcon,
path: '/app/database/inventory',
count: ingredients.length,
minimum: 3,
recommended: 10,
isComplete: ingredients.length >= 3,
description: t('dashboard:config.add_ingredients', 'Add at least {{count}} ingredients', { count: 3 }),
},
{
id: 'suppliers',
title: t('dashboard:config.suppliers', 'Suppliers'),
icon: SafeUsersIcon,
path: '/app/database/suppliers',
count: suppliers.length,
minimum: 1,
recommended: 3,
isComplete: suppliers.length >= 1,
description: t('dashboard:config.add_supplier', 'Add your first supplier'),
},
{
id: 'recipes',
title: t('dashboard:config.recipes', 'Recipes'),
icon: SafeBookOpenIcon,
path: '/app/database/recipes',
count: recipes.length,
minimum: 1,
recommended: 3,
isComplete: recipes.length >= 1,
description: t('dashboard:config.add_recipe', 'Create your first recipe'),
},
{
id: 'quality',
title: t('dashboard:config.quality', 'Quality Standards'),
icon: SafeShieldIcon,
path: '/app/operations/production/quality',
count: qualityTemplates.length,
minimum: 0,
recommended: 2,
isComplete: true, // Optional
description: t('dashboard:config.add_quality', 'Add quality checks (optional)'),
},
];
return sections;
}, [ingredients.length, suppliers.length, recipes.length, qualityTemplates.length, t]);
// Calculate overall progress
const { completedSections, totalSections, progressPercentage, criticalMissing, recommendedMissing } = useMemo(() => {
// If data is still loading, use stored value as fallback to prevent flickering
if (loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality) {
return {
completedSections: 0,
totalSections: 4, // 4 required sections
progressPercentage: setupProgressFromStorage, // Use stored value during loading
criticalMissing: [],
recommendedMissing: [],
};
}
// Guard against undefined or invalid setupSections
if (!setupSections || !Array.isArray(setupSections) || setupSections.length === 0) {
return {
completedSections: 0,
totalSections: 0,
progressPercentage: 100, // Default to 100% to avoid blocking dashboard
criticalMissing: [],
recommendedMissing: [],
};
}
const requiredSections = setupSections.filter(s => s.id !== 'quality');
const completed = requiredSections.filter(s => s.isComplete).length;
const total = requiredSections.length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 100;
const critical = setupSections.filter(s => !s.isComplete && s.id !== 'quality');
const recommended = setupSections.filter(s => s.count < s.recommended);
// PHASE 1 OPTIMIZATION: Cache progress to localStorage for next page load
try {
localStorage.setItem(`setup_progress_${tenantId}`, percentage.toString());
} catch {
// Ignore storage errors
}
return {
completedSections: completed,
totalSections: total,
progressPercentage: percentage,
criticalMissing: critical,
recommendedMissing: recommended,
};
}, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]);
const handleAddWizardComplete = (itemType: ItemType, data?: any) => {
console.log('Item created:', itemType, data);
// 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)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Cmd+K (Mac) or Ctrl+K (Windows/Linux)
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
setIsAddWizardOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Demo tour auto-start logic - using ref to prevent infinite loops
useEffect(() => {
// Only check tour start on initial render to prevent infinite loops
if (typeof window !== 'undefined') {
// Ensure we don't run this effect multiple times by checking a flag
const tourCheckDone = sessionStorage.getItem('dashboard_tour_check_done');
if (!tourCheckDone) {
sessionStorage.setItem('dashboard_tour_check_done', 'true');
console.log('[Dashboard] Demo mode:', isDemoMode);
const shouldStart = shouldStartTour();
console.log('[Dashboard] Should start tour:', shouldStart);
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 && (shouldStart || 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]); // Run only once after initial render
// 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 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 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">
{/* 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 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 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 bg-[var(--bg-tertiary)] border border-[var(--border-secondary)]">
K
</kbd>
</span>
</button>
</div>
</div>
{/* Setup Flow - Three States */}
{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>
) : progressPercentage < 50 ? (
/* STATE 1: Critical Missing (<50%) - Full-page blocker */
<SetupWizardBlocker
criticalSections={setupSections}
/>
) : (
/* STATE 2 & 3: Dashboard with optional banner */
<>
{/* Optional Setup Banner (50-99%) - Collapsible, Dismissible */}
{progressPercentage < 100 && recommendedMissing.length > 0 && (
<CollapsibleSetupBanner
remainingSections={recommendedMissing}
progressPercentage={progressPercentage}
/>
)}
{/* Main Dashboard Layout - 4 New Focused Blocks */}
<div className="space-y-6">
{/* BLOCK 1: System Status + AI Summary */}
<div data-tour="dashboard-stats">
<SystemStatusBlock
data={dashboardData}
loading={dashboardLoading}
/>
</div>
{/* 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>
{/* BLOCK 3: Pending Deliveries (Overdue + Today) */}
<div data-tour="pending-deliveries">
<PendingDeliveriesBlock
overdueDeliveries={dashboardData?.overdueDeliveries || []}
pendingDeliveries={dashboardData?.pendingDeliveries || []}
loading={dashboardLoading}
/>
</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>
</>
)}
</div>
{/* Mobile-friendly bottom padding */}
<div className="h-20 md:hidden"></div>
{/* Unified Add Wizard */}
<UnifiedAddWizard
isOpen={isAddWizardOpen}
onClose={() => setIsAddWizardOpen(false)}
onComplete={handleAddWizardComplete}
/>
{/* Purchase Order Details Modal - Using Unified Component */}
{selectedPOId && (
<UnifiedPurchaseOrderModal
poId={selectedPOId}
tenantId={tenantId}
isOpen={isPOModalOpen}
initialMode={poModalMode}
onClose={() => {
setIsPOModalOpen(false);
setSelectedPOId(null);
setPOModalMode('view');
// SSE events will handle most updates automatically
refetchDashboard();
}}
onApprove={handleApprove}
onReject={handleReject}
showApprovalActions={true}
/>
)}
</div>
);
}
/**
* Main Dashboard Page
* Conditionally renders either the Enterprise Dashboard or the Bakery Dashboard
* based on the user's subscription tier.
*/
export function DashboardPage() {
const { subscriptionInfo } = useSubscription();
const { currentTenant } = useTenant();
const { plan, loading } = subscriptionInfo;
const tenantId = currentTenant?.id;
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2" style={{ borderColor: 'var(--color-primary)' }}></div>
</div>
);
}
if (plan === SUBSCRIPTION_TIERS.ENTERPRISE) {
return <EnterpriseDashboardPage tenantId={tenantId} />;
}
return <BakeryDashboard />;
}
export default DashboardPage;