diff --git a/.env.example b/.env.example index d0e28d9..e28cc4f 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,7 @@ DIRECT_URL="postgresql://postgres:password@db.supabase.co:5432/postgres" VITE_SUPABASE_URL="https://your-project.supabase.co" VITE_SUPABASE_ANON_KEY="your-anon-key" SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" -SESSION_SECRET="sua-chave-secreta" \ No newline at end of file +SESSION_SECRET="sua-chave-secreta" +# Opcional: conexões do pool do Prisma por processo (padrão: 10 em produção, 2 fora dela). +# O pooler do Supabase em modo sessão aceita 15 clientes no total. +# DB_CONNECTION_LIMIT=2 diff --git a/.gitignore b/.gitignore index 60903a6..b9a7a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* *.swp *.swo localhost.har + +# Specs e rascunhos locais +.tmp/ diff --git a/app/AppRoutes.tsx b/app/AppRoutes.tsx index 3f28727..5c2761e 100644 --- a/app/AppRoutes.tsx +++ b/app/AppRoutes.tsx @@ -1,12 +1,16 @@ +import { lazy, Suspense } from "react"; import { Navigate, Outlet, Route, Routes, useLocation } from "react-router"; import { useAuth } from "~/context/AuthContext"; import { AppLayout } from "~/components/AppLayout"; -import { LoginPage } from "~/pages/LoginPage"; -import { DashboardPage } from "~/pages/DashboardPage"; import { OperacoesPage } from "~/pages/OperacoesPage"; -import { AutomacoesPage } from "~/pages/AutomacoesPage"; -import { UsuariosPage } from "~/pages/UsuariosPage"; -import { PerfilPage } from "~/pages/PerfilPage"; +import { ehAdmin, ehGestor } from "~/utils/cargos"; + +//Páginas menos acessadas ficam em chunks separados, baixados só quando visitadas +const LoginPage = lazy(() => import("~/pages/LoginPage").then((m) => ({ default: m.LoginPage }))); +const DashboardPage = lazy(() => import("~/pages/DashboardPage").then((m) => ({ default: m.DashboardPage }))); +const AutomacoesPage = lazy(() => import("~/pages/AutomacoesPage").then((m) => ({ default: m.AutomacoesPage }))); +const UsuariosPage = lazy(() => import("~/pages/UsuariosPage").then((m) => ({ default: m.UsuariosPage }))); +const PerfilPage = lazy(() => import("~/pages/PerfilPage").then((m) => ({ default: m.PerfilPage }))); function SplashScreen() { return ( @@ -20,7 +24,9 @@ function SplashScreen() { -

Carregando...

+
+
+
); @@ -35,32 +41,36 @@ function RequireAuth() { return ; } -function RequireAdmin() { +function RequireCargo({ permitido }: { permitido: (user: any) => boolean }) { const { user } = useAuth(); - if (user?.app_metadata?.role !== "admin") return ; + if (!permitido(user)) return ; return ; } export function AppRoutes() { return ( - - } /> + }> + + } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + }> + } /> + + }> + } /> + - - } /> - + } /> + + ); } diff --git a/app/components/AppLayout.tsx b/app/components/AppLayout.tsx index ca81290..6a3b181 100644 --- a/app/components/AppLayout.tsx +++ b/app/components/AppLayout.tsx @@ -1,10 +1,23 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { Outlet } from "react-router"; import { useAuth } from "~/context/AuthContext"; import { UIContext } from "~/hooks/use-ui"; import { useInit } from "~/lib/query"; +import { iniciar, terminar } from "~/lib/loading"; import { Sidebar } from "~/components/Sidebar"; import { GlobalModal } from "~/components/GlobalModal"; +import { TopProgress } from "~/components/TopProgress"; + +// Fallback do Suspense interno: enquanto o chunk de uma página lazy baixa, +// acende a faixa do topo (mesmo contador de lib/loading.ts) sem desmontar a +// sidebar nem trocar o layout por uma tela de carregamento. +function RotaCarregando() { + useEffect(() => { + iniciar(); + return () => terminar(); + }, []); + return null; +} export function AppLayout() { const { user } = useAuth(); @@ -59,6 +72,7 @@ export function AppLayout() { return ( +
- + {/* Boundary interno: enquanto o chunk de uma página lazy baixa, a + sidebar continua no lugar (o Suspense de AppRoutes só cobre o login) */} + }> + +
diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 9261a47..64eede9 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -1,11 +1,11 @@ -import { CheckCircle2, Inbox, LayoutDashboard, Moon, Plus, Search, ShieldCheck, Sun, Truck, User as UserIcon, X, Zap, Loader2, Menu } from "lucide-react"; +import { CheckCircle2, Inbox, LayoutDashboard, Moon, Plus, Search, ShieldCheck, Sun, Truck, User as UserIcon, X, Zap, Menu } from "lucide-react"; import React, { useCallback, useMemo, useState } from "react"; import { NavLink } from "react-router"; -import { useIsFetching } from "@tanstack/react-query"; import { buscarNomeUsuario } from "~/utils/formatters"; +import { ehAdmin, ehGestor } from "~/utils/cargos"; import { api, errorMessage } from "~/lib/api"; import { queryClient, queryKeys, useFaturistas } from "~/lib/query"; -import { prefetchOperacoes } from "~/hooks/useOperacoesGridData"; +import { prefetchOperacoes, cancelPrefetch } from "~/hooks/useOperacoesGridData"; import { useUI } from "~/hooks/use-ui"; import { COLOR_NAMES, PRESET_COLORS, SidebarFolderItem } from "./SidebarFolderItem"; @@ -31,10 +31,7 @@ export const Sidebar = React.memo(({ setIsCollapsed }: SidebarProps) => { - const isFetching = useIsFetching() > 0; const { alert: showAlert } = useUI(); - const { data: faturistasData } = useFaturistas(); - const faturistas = faturistasData?.faturistas || []; const [newFolderName, setNewFolderName] = useState(""); const [newFolderColor, setNewFolderColor] = useState(PRESET_COLORS[0]); @@ -44,6 +41,13 @@ export const Sidebar = React.memo(({ const [searchQuery, setSearchQuery] = useState(""); const [filterColor, setFilterColor] = useState(""); + const isAdmin = ehAdmin(user); + const isGestor = ehGestor(user); + + // Só busca a lista de faturistas (gestor/admin) com o formulário de nova pasta aberto. + const { data: faturistasData } = useFaturistas(isAddingFolder && isGestor); + const faturistas = faturistasData?.faturistas || []; + const normalize = (value: string) => value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); const filteredPastas = useMemo(() => { @@ -57,10 +61,14 @@ export const Sidebar = React.memo(({ const handleCreateFolder = useCallback(async (e: React.FormEvent) => { e.preventDefault(); - if (!newFolderName.trim() || !newFolderFaturista || isCreating) return; + if (!newFolderName.trim() || isCreating) return; setIsCreating(true); try { - await api.post("/pastas", { nome: newFolderName, cor: newFolderColor, faturistaId: newFolderFaturista }); + await api.post("/pastas", { + nome: newFolderName, + cor: newFolderColor, + ...(isGestor && { faturistaId: newFolderFaturista }), + }); setNewFolderName(""); setNewFolderColor(PRESET_COLORS[0]); setNewFolderFaturista(""); @@ -71,18 +79,14 @@ export const Sidebar = React.memo(({ } finally { setIsCreating(false); } - }, [newFolderName, newFolderColor, newFolderFaturista, isCreating, showAlert]); + }, [newFolderName, newFolderColor, newFolderFaturista, isGestor, isCreating, showAlert]); return (