All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
187 lines
7.6 KiB
TypeScript
187 lines
7.6 KiB
TypeScript
import { useCallback, useState, useEffect } from 'react';
|
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
|
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3, Shield } from 'lucide-react';
|
|
import type { DateRange, OrderData, StockData } from '../types';
|
|
import { fetchData, fetchStock, isSuperAdmin, logout } from '../dataService';
|
|
import { rangeForLastDays } from '../dateRanges';
|
|
|
|
const Layout = () => {
|
|
const location = useLocation();
|
|
const needsRawData = location.pathname.startsWith('/products');
|
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
|
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
|
});
|
|
|
|
const [dateRange, setDateRange] = useState<DateRange>(() => {
|
|
const saved = localStorage.getItem('nexstar_date_range');
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
return { start: new Date(parsed.start), end: new Date(parsed.end) };
|
|
} catch (e) { console.error(e); }
|
|
}
|
|
return rangeForLastDays(30);
|
|
});
|
|
|
|
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
|
const [stockData, setStockData] = useState<StockData[]>([]);
|
|
const [isLoading, setIsLoading] = useState(needsRawData);
|
|
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
|
|
const saved = localStorage.getItem('nexstar_refresh_interval');
|
|
return saved ? Number(saved) : 0;
|
|
});
|
|
|
|
const loadData = useCallback(async (showLoading = false) => {
|
|
if (showLoading) setIsLoading(true);
|
|
try {
|
|
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
|
|
setOrdersData(data);
|
|
setStockData(stock);
|
|
} finally {
|
|
if (showLoading) setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!needsRawData) return;
|
|
|
|
// Product pages still depend on raw orders until their API migration is complete.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
void loadData(true);
|
|
}, [loadData, needsRawData]);
|
|
|
|
useEffect(() => {
|
|
if (refreshInterval === 0 || !needsRawData) return;
|
|
|
|
const intervalId = setInterval(() => {
|
|
loadData(false);
|
|
}, refreshInterval);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}, [loadData, needsRawData, refreshInterval]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
|
|
}, [refreshInterval]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem('nexstar_date_range', JSON.stringify({
|
|
start: dateRange.start.toISOString(),
|
|
end: dateRange.end.toISOString()
|
|
}));
|
|
}, [dateRange]);
|
|
|
|
const toggleSidebar = () => {
|
|
const newState = !isSidebarCollapsed;
|
|
setIsSidebarCollapsed(newState);
|
|
localStorage.setItem('graph_sidebar_collapsed', String(newState));
|
|
};
|
|
|
|
const appNavigation = [
|
|
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
|
{ name: 'Produtos', href: '/products', icon: Package },
|
|
{ name: 'Clientes', href: '/clients', icon: Users },
|
|
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
|
|
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
|
];
|
|
const adminNavigation = isSuperAdmin()
|
|
? [{ name: 'Usuários', href: '/admin/users', icon: Shield }]
|
|
: [];
|
|
const navigationSections = [
|
|
{ label: 'Painel', items: appNavigation },
|
|
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []),
|
|
];
|
|
|
|
return (
|
|
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden">
|
|
{/* Sidebar */}
|
|
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}>
|
|
<div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`p-1.5 bg-brand-primary/20 rounded-lg text-brand-primary`}>
|
|
<BarChart3 className="w-6 h-6" />
|
|
</div>
|
|
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
|
|
</div>
|
|
{!isSidebarCollapsed && (
|
|
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isSidebarCollapsed && (
|
|
<div className="p-4 border-b border-dark-border flex justify-center">
|
|
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<nav className="flex-1 space-y-6 overflow-y-auto p-4">
|
|
{navigationSections.map((section) => (
|
|
<div key={section.label} className="space-y-2">
|
|
{!isSidebarCollapsed && (
|
|
<div className="px-3 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
|
{section.label}
|
|
</div>
|
|
)}
|
|
{section.items.map((item) => {
|
|
const isActive = location.pathname === item.href || (item.href !== '/graph' && location.pathname.startsWith(item.href));
|
|
return (
|
|
<Link
|
|
key={item.name}
|
|
to={item.href}
|
|
className={`flex items-center rounded-xl px-4 py-3 transition-all ${
|
|
isSidebarCollapsed ? 'justify-center' : 'space-x-3'
|
|
} ${
|
|
isActive
|
|
? 'bg-brand-primary/10 text-brand-primary font-semibold shadow-md shadow-brand-primary/5'
|
|
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text'
|
|
}`}
|
|
title={isSidebarCollapsed ? item.name : undefined}
|
|
>
|
|
<item.icon className="h-5 w-5 shrink-0" />
|
|
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="p-4 border-t border-dark-border">
|
|
<button
|
|
onClick={logout}
|
|
className={`w-full flex items-center text-red-500 hover:bg-red-500/10 px-4 py-3 rounded-xl transition-all cursor-pointer ${isSidebarCollapsed ? 'justify-center' : 'space-x-3'}`}
|
|
>
|
|
<LogOut className="w-5 h-5 shrink-0" />
|
|
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 flex flex-col h-screen overflow-hidden">
|
|
{/* Header */}
|
|
<header className="h-20 bg-dark-header border-b border-dark-border flex items-center px-8 shrink-0">
|
|
<h2 className="text-xl font-bold text-dark-text">Painel de Análise</h2>
|
|
</header>
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 overflow-y-auto p-8 relative">
|
|
{needsRawData && isLoading && (
|
|
<div className="absolute right-8 top-8 z-10 flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted shadow-sm">
|
|
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
|
Atualizando dados
|
|
</div>
|
|
)}
|
|
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Layout;
|