Files
igny8/frontend/src/layout/AppLayout.tsx
IGNY8 VPS (Salman) 4fe68cc271 ui frotneedn fixes
2025-11-26 06:47:23 +00:00

333 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { SidebarProvider, useSidebar } from "../context/SidebarContext";
import { Outlet } from "react-router-dom";
import AppHeader from "./AppHeader";
import Backdrop from "./Backdrop";
import AppSidebar from "./AppSidebar";
import { useSiteStore } from "../store/siteStore";
import { useSectorStore } from "../store/sectorStore";
import { useAuthStore } from "../store/authStore";
import { useBillingStore } from "../store/billingStore";
import { useHeaderMetrics } from "../context/HeaderMetricsContext";
import { useErrorHandler } from "../hooks/useErrorHandler";
import { trackLoading } from "../components/common/LoadingStateMonitor";
import ResourceDebugOverlay from "../components/debug/ResourceDebugOverlay";
const LayoutContent: React.FC = () => {
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const { loadActiveSite, activeSite } = useSiteStore();
const { loadSectorsForSite } = useSectorStore();
const { refreshUser, isAuthenticated } = useAuthStore();
const { balance, loadBalance } = useBillingStore();
const { setMetrics } = useHeaderMetrics();
const { addError } = useErrorHandler('AppLayout');
const hasLoadedSite = useRef(false);
const lastSiteId = useRef<number | null>(null);
const isLoadingSite = useRef(false);
const isLoadingSector = useRef(false);
const [debugEnabled, setDebugEnabled] = useState(false);
const lastUserRefresh = useRef<number>(0);
// Initialize site store on mount - only once, but only if authenticated
useEffect(() => {
// Only load sites if user is authenticated AND has a token
if (!isAuthenticated) return;
// Check if token exists - if not, wait a bit for Zustand persist to write it
const checkTokenAndLoad = () => {
const authState = useAuthStore.getState();
if (!authState?.token) {
// Token not available yet - wait a bit and retry (Zustand persist might still be writing)
setTimeout(() => {
const retryAuthState = useAuthStore.getState();
if (retryAuthState?.token && !hasLoadedSite.current && !isLoadingSite.current) {
loadSites();
}
}, 100); // Wait 100ms for persist to write
return;
}
loadSites();
};
const loadSites = () => {
if (!hasLoadedSite.current && !isLoadingSite.current) {
hasLoadedSite.current = true;
isLoadingSite.current = true;
trackLoading('site-loading', true);
// Add timeout to prevent infinite loading
// Match API timeout (30s) + buffer for network delays
const timeoutId = setTimeout(() => {
if (isLoadingSite.current) {
console.error('AppLayout: Site loading timeout after 35 seconds');
trackLoading('site-loading', false);
isLoadingSite.current = false;
addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite');
}
}, 35000); // 35 seconds to match API timeout (30s) + buffer
loadActiveSite()
.catch((error) => {
// Don't log 403 errors as they're expected when not authenticated
if (error.status !== 403) {
console.error('AppLayout: Error loading active site:', error);
addError(error, 'AppLayout.loadActiveSite');
}
})
.finally(() => {
clearTimeout(timeoutId);
trackLoading('site-loading', false);
isLoadingSite.current = false;
});
}
};
checkTokenAndLoad();
}, [isAuthenticated]); // Run when authentication state changes
// Load sectors when active site changes (by ID, not object reference)
useEffect(() => {
const currentSiteId = activeSite?.id ?? null;
// Only load if:
// 1. We have a site ID
// 2. The site is active (inactive sites can't have accessible sectors)
// 3. It's different from the last one we loaded
// 4. We're not already loading
if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) {
lastSiteId.current = currentSiteId;
isLoadingSector.current = true;
trackLoading('sector-loading', true);
// Add timeout to prevent infinite loading
// Match API timeout (30s) + buffer for network delays
const timeoutId = setTimeout(() => {
if (isLoadingSector.current) {
console.error('AppLayout: Sector loading timeout after 35 seconds');
trackLoading('sector-loading', false);
isLoadingSector.current = false;
addError(new Error('Sector loading timeout - check network connection'), 'AppLayout.loadSectorsForSite');
}
}, 35000); // 35 seconds to match API timeout (30s) + buffer
loadSectorsForSite(currentSiteId)
.catch((error) => {
// Don't log 403/404 errors as they're expected for inactive sites
if (error.status !== 403 && error.status !== 404) {
console.error('AppLayout: Error loading sectors:', error);
addError(error, 'AppLayout.loadSectorsForSite');
}
})
.finally(() => {
clearTimeout(timeoutId);
trackLoading('sector-loading', false);
isLoadingSector.current = false;
});
} else if (currentSiteId && !activeSite?.is_active) {
// Site is inactive - clear sectors and reset lastSiteId
lastSiteId.current = null;
const { useSectorStore } = require('../store/sectorStore');
useSectorStore.getState().clearActiveSector();
}
}, [activeSite?.id, activeSite?.is_active]); // Depend on both ID and is_active
// Refresh user data on mount and when app version changes (after code updates)
// This ensures changes are reflected immediately without requiring re-login
useEffect(() => {
if (!isAuthenticated) return;
const APP_VERSION = import.meta.env.VITE_APP_VERSION || '2.0.2';
const VERSION_STORAGE_KEY = 'igny8-app-version';
const refreshUserData = async (force = false) => {
const now = Date.now();
// Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced)
if (!force && now - lastUserRefresh.current < 30000) return;
// Check if token exists before making API call
const authState = useAuthStore.getState();
if (!authState?.token) {
// Token not available yet - wait a bit for Zustand persist to write it
setTimeout(() => {
const retryAuthState = useAuthStore.getState();
if (retryAuthState?.token && retryAuthState?.isAuthenticated) {
refreshUserData(force);
}
}, 100); // Wait 100ms for persist to write
return;
}
try {
lastUserRefresh.current = now;
await refreshUser();
// Store current version after successful refresh
if (force) {
localStorage.setItem(VERSION_STORAGE_KEY, APP_VERSION);
}
} catch (error) {
// Silently fail - user might still be authenticated
console.debug('User data refresh failed (non-critical):', error);
}
};
// Check if app version changed (indicates code update)
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion !== APP_VERSION) {
// Force refresh on version change
refreshUserData(true);
} else {
// Normal refresh on mount
refreshUserData();
}
// Refresh when window becomes visible (user switches back to tab)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshUserData();
}
};
// Refresh on window focus
const handleFocus = () => {
refreshUserData();
};
// Proactive token refresh - refresh token every 12 minutes (before 15-minute expiry)
// This prevents 401 errors and ensures seamless user experience
const tokenRefreshInterval = setInterval(async () => {
const authState = useAuthStore.getState();
const refreshToken = authState?.refreshToken;
if (refreshToken && authState?.isAuthenticated) {
try {
await authState.refreshToken();
console.debug('Token proactively refreshed');
} catch (error) {
console.debug('Proactive token refresh failed (will retry on next API call):', error);
}
}
}, 720000); // 12 minutes = 720000ms
// Periodic user data refresh every 2 minutes
const intervalId = setInterval(() => refreshUserData(), 120000);
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);
return () => {
clearInterval(tokenRefreshInterval);
clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
};
}, [isAuthenticated, refreshUser]);
// Load credit balance and set in header metrics
useEffect(() => {
if (!isAuthenticated) {
setMetrics([]);
return;
}
const billingState = useBillingStore.getState();
// Load balance if not already loaded and not currently loading
if (!balance && !billingState.loading) {
loadBalance().catch((error) => {
console.error('AppLayout: Error loading credit balance:', error);
// Don't show error to user - balance is not critical for app functionality
// But retry after a delay
setTimeout(() => {
if (!useBillingStore.getState().balance && !useBillingStore.getState().loading) {
loadBalance().catch(() => {
// Silently fail on retry too
});
}
}, 5000);
});
}
}, [isAuthenticated, balance, loadBalance, setMetrics]);
// Update header metrics when balance changes
// This sets credit balance which will be merged with page metrics by HeaderMetricsContext
useEffect(() => {
if (!isAuthenticated) {
// Only clear metrics when not authenticated (user logged out)
setMetrics([]);
return;
}
// If balance is null, don't clear metrics - let page metrics stay visible
// Only set credit metrics when balance is loaded
if (!balance) {
return;
}
// Determine accent color based on credit level
let accentColor: 'blue' | 'green' | 'amber' | 'purple' = 'blue';
if (balance.credits > 1000) {
accentColor = 'green';
} else if (balance.credits > 100) {
accentColor = 'blue';
} else if (balance.credits > 0) {
accentColor = 'amber';
} else {
accentColor = 'purple';
}
// Set credit balance (single metric with label "Credits" - HeaderMetricsContext will merge it)
setMetrics([{
label: 'Credits',
value: balance.credits,
accentColor,
}]);
}, [balance, isAuthenticated, setMetrics]);
// Listen for debug toggle changes
useEffect(() => {
const saved = localStorage.getItem('debug_resource_tracking_enabled');
setDebugEnabled(saved === 'true');
const handleToggle = (e: Event) => {
const customEvent = e as CustomEvent;
setDebugEnabled(customEvent.detail);
};
window.addEventListener('debug-resource-tracking-toggle', handleToggle);
return () => {
window.removeEventListener('debug-resource-tracking-toggle', handleToggle);
};
}, []);
return (
<div className="min-h-screen xl:flex">
<div>
<AppSidebar />
<Backdrop />
</div>
<div
className={`flex-1 transition-all duration-300 ease-in-out ${
isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
} ${isMobileOpen ? "ml-0" : ""} w-full max-w-full min-[1440px]:max-w-[90%]`}
>
<AppHeader />
<div className="p-4 pb-20 md:p-6 md:pb-24">
<Outlet />
</div>
{/* Resource Debug Overlay - Only visible when enabled by admin */}
<ResourceDebugOverlay enabled={debugEnabled} />
</div>
</div>
);
};
const AppLayout: React.FC = () => {
return (
<SidebarProvider>
<LayoutContent />
</SidebarProvider>
);
};
export default AppLayout;