Add frontend loading imporvements
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { subscriptionService } from '../services/subscription';
|
||||
import {
|
||||
SUBSCRIPTION_TIERS,
|
||||
@@ -34,49 +35,32 @@ export interface SubscriptionInfo {
|
||||
}
|
||||
|
||||
export const useSubscription = () => {
|
||||
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo>({
|
||||
plan: 'starter',
|
||||
status: 'active',
|
||||
features: {},
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
const { notifySubscriptionChanged, subscriptionVersion } = useSubscriptionEvents();
|
||||
const { subscriptionVersion } = useSubscriptionEvents();
|
||||
|
||||
// Load subscription data
|
||||
const loadSubscriptionData = useCallback(async () => {
|
||||
if (!tenantId) {
|
||||
setSubscriptionInfo(prev => ({ ...prev, loading: false, error: 'No tenant ID available' }));
|
||||
return;
|
||||
}
|
||||
// Initialize with tenant's subscription_plan if available, otherwise default to starter
|
||||
const initialPlan = currentTenant?.subscription_plan || currentTenant?.subscription_tier || 'starter';
|
||||
|
||||
try {
|
||||
setSubscriptionInfo(prev => ({ ...prev, loading: true, error: undefined }));
|
||||
// Use React Query to fetch subscription data (automatically deduplicates & caches)
|
||||
const { data: usageSummary, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['subscription-usage', tenantId, subscriptionVersion],
|
||||
queryFn: () => subscriptionService.getUsageSummary(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // Cache for 30 seconds (matches backend cache)
|
||||
gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const usageSummary = await subscriptionService.getUsageSummary(tenantId);
|
||||
|
||||
setSubscriptionInfo({
|
||||
plan: usageSummary.plan,
|
||||
status: usageSummary.status,
|
||||
features: usageSummary.usage || {},
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading subscription data:', error);
|
||||
setSubscriptionInfo(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: 'Failed to load subscription data'
|
||||
}));
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptionData();
|
||||
}, [loadSubscriptionData, subscriptionVersion]);
|
||||
// Derive subscription info from query data or tenant fallback
|
||||
const subscriptionInfo: SubscriptionInfo = {
|
||||
plan: usageSummary?.plan || initialPlan,
|
||||
status: usageSummary?.status || 'active',
|
||||
features: usageSummary?.usage || {},
|
||||
loading: isLoading,
|
||||
error: error ? 'Failed to load subscription data' : undefined,
|
||||
};
|
||||
|
||||
// Check if user has a specific feature
|
||||
const hasFeature = useCallback(async (featureName: string): Promise<SubscriptionFeature> => {
|
||||
@@ -175,7 +159,7 @@ export const useSubscription = () => {
|
||||
canAccessForecasting,
|
||||
canAccessAIInsights,
|
||||
checkLimits,
|
||||
refreshSubscription: loadSubscriptionData,
|
||||
refreshSubscription: refetch,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -382,7 +382,8 @@ export function useControlPanelData(tenantId: string) {
|
||||
},
|
||||
enabled: !!tenantId,
|
||||
staleTime: 20000, // 20 seconds
|
||||
refetchOnMount: 'always',
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
|
||||
68
frontend/src/components/dashboard/DashboardSkeleton.tsx
Normal file
68
frontend/src/components/dashboard/DashboardSkeleton.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
|
||||
export const DashboardSkeleton: React.FC = () => (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
{/* System Status Block Skeleton */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 border border-[var(--border-primary)]">
|
||||
<div className="h-6 w-48 bg-[var(--bg-tertiary)] rounded mb-4" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="bg-[var(--bg-primary)] rounded-lg p-4">
|
||||
<div className="h-4 w-32 bg-[var(--bg-tertiary)] rounded mb-2" />
|
||||
<div className="h-8 w-16 bg-[var(--bg-tertiary)] rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Purchases Skeleton */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 border border-[var(--border-primary)]">
|
||||
<div className="h-6 w-40 bg-[var(--bg-tertiary)] rounded mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-[var(--bg-primary)] rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-48 bg-[var(--bg-tertiary)] rounded mb-2" />
|
||||
<div className="h-4 w-32 bg-[var(--bg-tertiary)] rounded" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-10 w-20 bg-[var(--bg-tertiary)] rounded" />
|
||||
<div className="h-10 w-20 bg-[var(--bg-tertiary)] rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production Status Skeleton */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 border border-[var(--border-primary)]">
|
||||
<div className="h-6 w-44 bg-[var(--bg-tertiary)] rounded mb-4" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-[var(--bg-primary)] rounded-lg p-4">
|
||||
<div className="h-4 w-36 bg-[var(--bg-tertiary)] rounded mb-3" />
|
||||
<div className="h-6 w-24 bg-[var(--bg-tertiary)] rounded mb-2" />
|
||||
<div className="h-4 w-28 bg-[var(--bg-tertiary)] rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts Skeleton */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 border border-[var(--border-primary)]">
|
||||
<div className="h-6 w-32 bg-[var(--bg-tertiary)] rounded mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="bg-[var(--bg-primary)] rounded-lg p-4 flex items-start gap-3">
|
||||
<div className="h-10 w-10 bg-[var(--bg-tertiary)] rounded-full flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-56 bg-[var(--bg-tertiary)] rounded mb-2" />
|
||||
<div className="h-4 w-full bg-[var(--bg-tertiary)] rounded mb-2" />
|
||||
<div className="h-4 w-3/4 bg-[var(--bg-tertiary)] rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -185,7 +185,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
|
||||
// Get subscription-aware navigation routes
|
||||
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
||||
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||
const { filteredRoutes: subscriptionFilteredRoutes, isLoading: subscriptionLoading } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||
|
||||
// Map route paths to translation keys
|
||||
const getTranslationKey = (routePath: string): string => {
|
||||
@@ -845,9 +845,23 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className={clsx('flex-1 overflow-y-auto overflow-x-hidden', isCollapsed ? 'px-1 py-4' : 'p-4')}>
|
||||
<ul className={clsx(isCollapsed ? 'space-y-1 flex flex-col items-center' : 'space-y-2')}>
|
||||
{filteredItems.map(item => renderItem(item))}
|
||||
</ul>
|
||||
{subscriptionLoading ? (
|
||||
/* Skeleton loading state while subscription data is loading */
|
||||
<ul className={clsx(isCollapsed ? 'space-y-1 flex flex-col items-center' : 'space-y-2')}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<li key={i} className="animate-pulse">
|
||||
<div className={clsx(
|
||||
'rounded-lg bg-[var(--bg-tertiary)]',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full'
|
||||
)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className={clsx(isCollapsed ? 'space-y-1 flex flex-col items-center' : 'space-y-2')}>
|
||||
{filteredItems.map(item => renderItem(item))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Profile section */}
|
||||
|
||||
@@ -39,8 +39,19 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const initializeAuth = async () => {
|
||||
setIsInitializing(true);
|
||||
|
||||
// Wait a bit for zustand persist to rehydrate
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Check if zustand has already rehydrated
|
||||
if (!(useAuthStore.persist as any).hasHydrated?.()) {
|
||||
// Wait for rehydration event with minimal timeout fallback
|
||||
await Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
const unsubscribe = useAuthStore.persist.onFinishHydration(() => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
new Promise(resolve => setTimeout(resolve, 50))
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if we have stored auth data
|
||||
if (authStore.token && authStore.refreshToken) {
|
||||
|
||||
@@ -35,12 +35,15 @@ interface SSEProviderProps {
|
||||
export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastEvent, setLastEvent] = useState<SSEEvent | null>(null);
|
||||
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const eventListenersRef = useRef<Map<string, Set<(data: any) => void>>>(new Map());
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const reconnectAttempts = useRef(0);
|
||||
|
||||
|
||||
// Global deduplication: Track processed event IDs to prevent duplicate callbacks
|
||||
const processedEventIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const { isAuthenticated, token } = useAuthStore();
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
@@ -130,6 +133,23 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
eventSource.addEventListener('alert', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// GLOBAL DEDUPLICATION: Skip if this event was already processed
|
||||
if (data.id && processedEventIdsRef.current.has(data.id)) {
|
||||
console.log('⏭️ [SSE] Skipping duplicate alert:', data.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark event as processed
|
||||
if (data.id) {
|
||||
processedEventIdsRef.current.add(data.id);
|
||||
// Limit cache size (keep last 1000 event IDs)
|
||||
if (processedEventIdsRef.current.size > 1000) {
|
||||
const firstId = Array.from(processedEventIdsRef.current)[0];
|
||||
processedEventIdsRef.current.delete(firstId);
|
||||
}
|
||||
}
|
||||
|
||||
const sseEvent: SSEEvent = {
|
||||
type: 'alert',
|
||||
data,
|
||||
@@ -208,6 +228,22 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
eventSource.addEventListener('notification', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// GLOBAL DEDUPLICATION: Skip if this event was already processed
|
||||
if (data.id && processedEventIdsRef.current.has(data.id)) {
|
||||
console.log('⏭️ [SSE] Skipping duplicate notification:', data.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark event as processed
|
||||
if (data.id) {
|
||||
processedEventIdsRef.current.add(data.id);
|
||||
if (processedEventIdsRef.current.size > 1000) {
|
||||
const firstId = Array.from(processedEventIdsRef.current)[0];
|
||||
processedEventIdsRef.current.delete(firstId);
|
||||
}
|
||||
}
|
||||
|
||||
const sseEvent: SSEEvent = {
|
||||
type: 'notification',
|
||||
data,
|
||||
|
||||
51
frontend/src/hooks/useOnboardingStatus.ts
Normal file
51
frontend/src/hooks/useOnboardingStatus.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
interface OnboardingStatus {
|
||||
ingredients_count: number;
|
||||
suppliers_count: number;
|
||||
recipes_count: number;
|
||||
has_minimum_setup: boolean;
|
||||
progress_percentage: number;
|
||||
requirements: {
|
||||
ingredients: {
|
||||
current: number;
|
||||
minimum: number;
|
||||
met: boolean;
|
||||
};
|
||||
suppliers: {
|
||||
current: number;
|
||||
minimum: number;
|
||||
met: boolean;
|
||||
};
|
||||
recipes: {
|
||||
current: number;
|
||||
minimum: number;
|
||||
met: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const useOnboardingStatus = (tenantId: string) => {
|
||||
return useQuery<OnboardingStatus>({
|
||||
queryKey: ['onboarding-status', tenantId],
|
||||
queryFn: async () => {
|
||||
console.log('[useOnboardingStatus] Fetching for tenant:', tenantId);
|
||||
try {
|
||||
// apiClient.get() already returns response.data (unwrapped)
|
||||
const data = await apiClient.get<OnboardingStatus>(
|
||||
`/tenants/${tenantId}/onboarding/status`
|
||||
);
|
||||
console.log('[useOnboardingStatus] Success:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[useOnboardingStatus] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: !!tenantId,
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
};
|
||||
@@ -25,15 +25,13 @@ import {
|
||||
useApprovePurchaseOrder,
|
||||
useStartProductionBatch,
|
||||
} from '../../api/hooks/useProfessionalDashboard';
|
||||
import { useControlPanelData, useControlPanelRealtimeSync } from '../../api/hooks/useControlPanelData';
|
||||
import { useControlPanelData } from '../../api/hooks/useControlPanelData';
|
||||
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 { useUserProgress } from '../../api/hooks/onboarding';
|
||||
import { useQualityTemplates } from '../../api/hooks/qualityTemplates';
|
||||
import { useOnboardingStatus } from '../../hooks/useOnboardingStatus';
|
||||
import { SetupWizardBlocker } from '../../components/dashboard/SetupWizardBlocker';
|
||||
import { CollapsibleSetupBanner } from '../../components/dashboard/CollapsibleSetupBanner';
|
||||
import { DashboardSkeleton } from '../../components/dashboard/DashboardSkeleton';
|
||||
import {
|
||||
SystemStatusBlock,
|
||||
PendingPurchasesBlock,
|
||||
@@ -69,49 +67,27 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
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]);
|
||||
// ALWAYS use lightweight onboarding status endpoint for ALL users (demo + authenticated)
|
||||
// This is faster and more efficient than fetching full datasets
|
||||
const { data: onboardingStatus, isLoading: loadingOnboarding } = useOnboardingStatus(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 : [];
|
||||
// DEBUG: Log onboarding status
|
||||
useEffect(() => {
|
||||
console.log('[DashboardPage] Onboarding Status:', {
|
||||
onboardingStatus,
|
||||
loadingOnboarding,
|
||||
tenantId,
|
||||
});
|
||||
}, [onboardingStatus, loadingOnboarding, tenantId]);
|
||||
|
||||
// NEW: Enhanced control panel data fetch with SSE integration
|
||||
// Note: useControlPanelData already includes SSE integration and auto-refetch
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading: dashboardLoading,
|
||||
refetch: refetchDashboard,
|
||||
} = useControlPanelData(tenantId);
|
||||
|
||||
// Enable enhanced SSE real-time state synchronization
|
||||
useControlPanelRealtimeSync(tenantId);
|
||||
|
||||
// Mutations
|
||||
const approvePO = useApprovePurchaseOrder();
|
||||
const rejectPO = useRejectPurchaseOrder();
|
||||
@@ -161,6 +137,12 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
const SafeBookOpenIcon = BookOpen;
|
||||
const SafeShieldIcon = Shield;
|
||||
|
||||
// ALWAYS use onboardingStatus counts for ALL users
|
||||
// This is lightweight and doesn't require fetching full datasets
|
||||
const ingredientsCount = onboardingStatus?.ingredients_count ?? 0;
|
||||
const suppliersCount = onboardingStatus?.suppliers_count ?? 0;
|
||||
const recipesCount = onboardingStatus?.recipes_count ?? 0;
|
||||
|
||||
// Validate that all icons are properly imported before using them
|
||||
const sections = [
|
||||
{
|
||||
@@ -168,10 +150,10 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
title: t('dashboard:config.inventory', 'Inventory'),
|
||||
icon: SafePackageIcon,
|
||||
path: '/app/database/inventory',
|
||||
count: ingredients.length,
|
||||
count: ingredientsCount,
|
||||
minimum: 3,
|
||||
recommended: 10,
|
||||
isComplete: ingredients.length >= 3,
|
||||
isComplete: ingredientsCount >= 3,
|
||||
description: t('dashboard:config.add_ingredients', 'Add at least {{count}} ingredients', { count: 3 }),
|
||||
},
|
||||
{
|
||||
@@ -179,10 +161,10 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
title: t('dashboard:config.suppliers', 'Suppliers'),
|
||||
icon: SafeUsersIcon,
|
||||
path: '/app/database/suppliers',
|
||||
count: suppliers.length,
|
||||
count: suppliersCount,
|
||||
minimum: 1,
|
||||
recommended: 3,
|
||||
isComplete: suppliers.length >= 1,
|
||||
isComplete: suppliersCount >= 1,
|
||||
description: t('dashboard:config.add_supplier', 'Add your first supplier'),
|
||||
},
|
||||
{
|
||||
@@ -190,10 +172,10 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
title: t('dashboard:config.recipes', 'Recipes'),
|
||||
icon: SafeBookOpenIcon,
|
||||
path: '/app/database/recipes',
|
||||
count: recipes.length,
|
||||
count: recipesCount,
|
||||
minimum: 1,
|
||||
recommended: 3,
|
||||
isComplete: recipes.length >= 1,
|
||||
isComplete: recipesCount >= 1,
|
||||
description: t('dashboard:config.add_recipe', 'Create your first recipe'),
|
||||
},
|
||||
{
|
||||
@@ -201,30 +183,50 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
title: t('dashboard:config.quality', 'Quality Standards'),
|
||||
icon: SafeShieldIcon,
|
||||
path: '/app/operations/production/quality',
|
||||
count: qualityTemplates.length,
|
||||
count: 0, // Quality templates are optional, not tracked in onboarding
|
||||
minimum: 0,
|
||||
recommended: 2,
|
||||
isComplete: true, // Optional
|
||||
isComplete: true, // Optional - always complete
|
||||
description: t('dashboard:config.add_quality', 'Add quality checks (optional)'),
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
}, [ingredients.length, suppliers.length, recipes.length, qualityTemplates.length, t]);
|
||||
}, [onboardingStatus, 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) {
|
||||
// If onboarding data is still loading, show loading state
|
||||
if (loadingOnboarding) {
|
||||
console.log('[DashboardPage] Progress calculation: Loading state');
|
||||
return {
|
||||
completedSections: 0,
|
||||
totalSections: 4, // 4 required sections
|
||||
progressPercentage: setupProgressFromStorage, // Use stored value during loading
|
||||
progressPercentage: 0, // Loading state
|
||||
criticalMissing: [],
|
||||
recommendedMissing: [],
|
||||
};
|
||||
}
|
||||
|
||||
// OPTIMIZATION: If we have onboarding status from API, use it directly
|
||||
if (onboardingStatus?.progress_percentage !== undefined) {
|
||||
const apiProgress = onboardingStatus.progress_percentage;
|
||||
console.log('[DashboardPage] Progress calculation: Using API progress', {
|
||||
apiProgress,
|
||||
has_minimum_setup: onboardingStatus.has_minimum_setup,
|
||||
onboardingStatus,
|
||||
});
|
||||
return {
|
||||
completedSections: onboardingStatus.has_minimum_setup ? 3 : 0,
|
||||
totalSections: 3,
|
||||
progressPercentage: apiProgress,
|
||||
criticalMissing: apiProgress < 50 ? setupSections.filter(s => s.id !== 'quality' && !s.isComplete) : [],
|
||||
recommendedMissing: setupSections.filter(s => s.count < s.recommended),
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[DashboardPage] Progress calculation: Fallback to manual calculation');
|
||||
|
||||
// Guard against undefined or invalid setupSections
|
||||
if (!setupSections || !Array.isArray(setupSections) || setupSections.length === 0) {
|
||||
return {
|
||||
@@ -258,7 +260,7 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
criticalMissing: critical,
|
||||
recommendedMissing: recommended,
|
||||
};
|
||||
}, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]);
|
||||
}, [onboardingStatus, setupSections, tenantId, loadingOnboarding]);
|
||||
|
||||
const handleAddWizardComplete = (itemType: ItemType, data?: any) => {
|
||||
console.log('Item created:', itemType, data);
|
||||
@@ -302,7 +304,7 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
const redirectStartStep = parseInt(sessionStorage.getItem('demo_tour_start_step') || '0', 10);
|
||||
|
||||
if (isDemoMode && (shouldStart || shouldStartFromRedirect)) {
|
||||
console.log('[Dashboard] Starting tour in 1.5s...');
|
||||
console.log('[Dashboard] Starting tour...');
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[Dashboard] Executing startTour()');
|
||||
if (shouldStartFromRedirect) {
|
||||
@@ -316,7 +318,7 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
startTour();
|
||||
clearTourStartPending();
|
||||
}
|
||||
}, 1500);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -362,8 +364,8 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
</div>
|
||||
|
||||
{/* Setup Flow - Three States */}
|
||||
{loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality ? (
|
||||
/* Loading state - only show spinner until setup data is ready */
|
||||
{loadingOnboarding ? (
|
||||
/* Loading state for onboarding checks */
|
||||
<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>
|
||||
@@ -384,62 +386,66 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
)}
|
||||
|
||||
{/* 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 || []}
|
||||
alerts={dashboardData?.alerts || []}
|
||||
equipmentAlerts={dashboardData?.equipmentAlerts || []}
|
||||
loading={dashboardLoading}
|
||||
onStartBatch={handleStartBatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BLOCK 5: AI Insights (Professional/Enterprise only) */}
|
||||
{(plan === SUBSCRIPTION_TIERS.PROFESSIONAL || plan === SUBSCRIPTION_TIERS.ENTERPRISE) && (
|
||||
<div data-tour="ai-insights">
|
||||
<AIInsightsBlock
|
||||
insights={dashboardData?.aiInsights || []}
|
||||
loading={dashboardLoading}
|
||||
onViewAll={() => {
|
||||
// Navigate to AI Insights page
|
||||
window.location.href = '/app/analytics/ai-insights';
|
||||
}}
|
||||
{dashboardLoading ? (
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* BLOCK 1: System Status + AI Summary */}
|
||||
<div data-tour="dashboard-stats">
|
||||
<SystemStatusBlock
|
||||
data={dashboardData}
|
||||
loading={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* BLOCK 2: Pending Purchases (PO Approvals) */}
|
||||
<div data-tour="pending-po-approvals">
|
||||
<PendingPurchasesBlock
|
||||
pendingPOs={dashboardData?.pendingPOs || []}
|
||||
loading={false}
|
||||
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={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BLOCK 4: Production Status (Late/Running/Pending) */}
|
||||
<div data-tour="execution-progress">
|
||||
<ProductionStatusBlock
|
||||
lateToStartBatches={dashboardData?.lateToStartBatches || []}
|
||||
runningBatches={dashboardData?.runningBatches || []}
|
||||
pendingBatches={dashboardData?.pendingBatches || []}
|
||||
alerts={dashboardData?.alerts || []}
|
||||
equipmentAlerts={dashboardData?.equipmentAlerts || []}
|
||||
loading={false}
|
||||
onStartBatch={handleStartBatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BLOCK 5: AI Insights (Professional/Enterprise only) */}
|
||||
{(plan === SUBSCRIPTION_TIERS.PROFESSIONAL || plan === SUBSCRIPTION_TIERS.ENTERPRISE) && (
|
||||
<div data-tour="ai-insights">
|
||||
<AIInsightsBlock
|
||||
insights={dashboardData?.aiInsights || []}
|
||||
loading={false}
|
||||
onViewAll={() => {
|
||||
// Navigate to AI Insights page
|
||||
window.location.href = '/app/analytics/ai-insights';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -295,10 +295,10 @@ const DemoPage = () => {
|
||||
|
||||
// BUG-010 FIX: Handle ready status separately from partial
|
||||
if (statusData.status === 'ready') {
|
||||
// Full success - set to 100% and navigate after delay
|
||||
// Full success - set to 100% and navigate immediately
|
||||
clearInterval(progressInterval);
|
||||
setCloneProgress(prev => ({ ...prev, overall: 100 }));
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// Reset state before navigation
|
||||
setCreatingTier(null);
|
||||
setProgressStartTime(null);
|
||||
@@ -311,7 +311,7 @@ const DemoPage = () => {
|
||||
});
|
||||
// Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier
|
||||
navigate('/app/dashboard');
|
||||
}, 1500); // Increased from 1000ms to show 100% completion
|
||||
});
|
||||
return;
|
||||
} else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') {
|
||||
// BUG-010 FIX: Show warning modal for partial status
|
||||
|
||||
@@ -131,68 +131,27 @@ export const useTenantInitializer = () => {
|
||||
console.log('✅ [TenantInitializer] Set API client tenant ID:', virtualTenantId);
|
||||
});
|
||||
|
||||
// For enterprise demos, wait for session to be ready, then load tenants
|
||||
// For enterprise demos, load child tenants immediately (session is already ready when we navigate here)
|
||||
if (demoAccountType === 'enterprise') {
|
||||
console.log('🔄 [TenantInitializer] Waiting for enterprise demo session to be ready...');
|
||||
console.log('🔄 [TenantInitializer] Loading available tenants for enterprise demo...');
|
||||
const mockUserId = 'demo-user';
|
||||
|
||||
// Poll session status until ready
|
||||
const pollSessionStatus = async (sessionId: string, maxAttempts = 30) => {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/demo-sessions/${sessionId}/status`);
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
console.log(`⏳ [TenantInitializer] Session status poll ${attempt}/${maxAttempts}:`, status.status);
|
||||
|
||||
if (status.status === 'ready') {
|
||||
console.log('✅ [TenantInitializer] Demo session is ready!');
|
||||
return true;
|
||||
} else if (status.status === 'failed') {
|
||||
console.error('❌ [TenantInitializer] Demo session failed:', status);
|
||||
return false;
|
||||
}
|
||||
// Status is 'initializing' or 'cloning_data' - continue polling
|
||||
import('../api/services/tenant').then(({ TenantService }) => {
|
||||
const tenantService = new TenantService();
|
||||
tenantService.getUserTenants(mockUserId)
|
||||
.then(tenants => {
|
||||
console.log('📋 [TenantInitializer] Loaded available tenants:', tenants.length);
|
||||
if (tenants.length === 0) {
|
||||
console.warn('⚠️ [TenantInitializer] No child tenants found yet - they may still be cloning');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ [TenantInitializer] Status poll ${attempt} failed:`, error);
|
||||
}
|
||||
|
||||
// Wait 1 second before next poll (except on last attempt)
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
console.error('❌ [TenantInitializer] Session readiness timeout after 30 seconds');
|
||||
return false;
|
||||
};
|
||||
|
||||
// Wait for session to be ready, then load tenants
|
||||
pollSessionStatus(demoSessionId).then(isReady => {
|
||||
if (isReady) {
|
||||
console.log('🔄 [TenantInitializer] Loading available tenants for enterprise demo...');
|
||||
const mockUserId = 'demo-user';
|
||||
|
||||
import('../api/services/tenant').then(({ TenantService }) => {
|
||||
const tenantService = new TenantService();
|
||||
tenantService.getUserTenants(mockUserId)
|
||||
.then(tenants => {
|
||||
console.log('📋 [TenantInitializer] Loaded available tenants:', tenants.length);
|
||||
if (tenants.length === 0) {
|
||||
console.warn('⚠️ [TenantInitializer] Session ready but no tenants found - possible sync issue');
|
||||
}
|
||||
// Update the tenant store with available tenants
|
||||
import('../stores/tenant.store').then(({ useTenantStore }) => {
|
||||
useTenantStore.getState().setAvailableTenants(tenants);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ [TenantInitializer] Failed to load available tenants:', error);
|
||||
});
|
||||
// Update the tenant store with available tenants
|
||||
import('../stores/tenant.store').then(({ useTenantStore }) => {
|
||||
useTenantStore.getState().setAvailableTenants(tenants);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ [TenantInitializer] Failed to load available tenants:', error);
|
||||
});
|
||||
} else {
|
||||
console.error('❌ [TenantInitializer] Cannot load tenants - session not ready');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user