Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ yarn-error.log*
*.swp
*.swo
localhost.har

# Specs e rascunhos locais
.tmp/
56 changes: 33 additions & 23 deletions app/AppRoutes.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -20,7 +24,9 @@ function SplashScreen() {
<circle cx="17" cy="18" r="2" />
</svg>
</div>
<p className="text-xs font-bold text-text-muted uppercase tracking-widest animate-pulse">Carregando...</p>
<div className="w-24 h-1 rounded-full bg-surface-light overflow-hidden">
<div className="h-full bg-primary rounded-full animate-loading" />
</div>
</div>
</div>
);
Expand All @@ -35,32 +41,36 @@ function RequireAuth() {
return <Outlet />;
}

function RequireAdmin() {
function RequireCargo({ permitido }: { permitido: (user: any) => boolean }) {
const { user } = useAuth();
if (user?.app_metadata?.role !== "admin") return <Navigate to="/" replace />;
if (!permitido(user)) return <Navigate to="/" replace />;
return <Outlet />;
}

export function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Suspense fallback={<SplashScreen />}>
<Routes>
<Route path="/login" element={<LoginPage />} />

<Route element={<RequireAuth />}>
<Route element={<AppLayout />}>
<Route index element={<Navigate to="/caixa-de-entrada" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="caixa-de-entrada" element={<OperacoesPage />} />
<Route path="pastas/:nome" element={<OperacoesPage />} />
<Route path="automacoes" element={<AutomacoesPage />} />
<Route path="perfil" element={<PerfilPage />} />
<Route element={<RequireAdmin />}>
<Route path="admin/usuarios" element={<UsuariosPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppLayout />}>
<Route index element={<Navigate to="/caixa-de-entrada" replace />} />
<Route path="caixa-de-entrada" element={<OperacoesPage />} />
<Route path="pastas/:nome" element={<OperacoesPage />} />
<Route path="perfil" element={<PerfilPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route element={<RequireCargo permitido={ehGestor} />}>
<Route path="automacoes" element={<AutomacoesPage />} />
</Route>
<Route element={<RequireCargo permitido={ehAdmin} />}>
<Route path="admin/usuarios" element={<UsuariosPage />} />
</Route>
</Route>
</Route>
</Route>

<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
22 changes: 20 additions & 2 deletions app/components/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -59,6 +72,7 @@ export function AppLayout() {

return (
<UIContext.Provider value={uiContextValue}>
<TopProgress />
<div className="flex h-screen w-screen overflow-hidden transition-colors duration-500">
<Sidebar
pastas={pastas}
Expand All @@ -71,7 +85,11 @@ export function AppLayout() {
setIsCollapsed={setIsCollapsed}
/>
<main className="flex-1 flex flex-col min-w-0 bg-transparent overflow-hidden h-full">
<Outlet />
{/* Boundary interno: enquanto o chunk de uma página lazy baixa, a
sidebar continua no lugar (o Suspense de AppRoutes só cobre o login) */}
<Suspense fallback={<RotaCarregando />}>
<Outlet />
</Suspense>
</main>
</div>

Expand Down
80 changes: 44 additions & 36 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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]);
Expand All @@ -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(() => {
Expand All @@ -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("");
Expand All @@ -71,18 +79,14 @@ export const Sidebar = React.memo(({
} finally {
setIsCreating(false);
}
}, [newFolderName, newFolderColor, newFolderFaturista, isCreating, showAlert]);
}, [newFolderName, newFolderColor, newFolderFaturista, isGestor, isCreating, showAlert]);

return (
<aside className={`${isCollapsed ? 'w-[72px]' : 'w-[240px]'} bg-card-bg dark:bg-bg border-r border-glass-border transition-all duration-300 flex flex-col relative z-20`}>
<div className="h-[64px] flex items-center px-4 border-b border-glass-border shrink-0">
<div className="flex items-center gap-2.5 overflow-hidden">
<div className="p-1.5 bg-primary rounded-lg text-white shrink-0 shadow-primary-glow">
{isFetching ? (
<Loader2 size={18} strokeWidth={2.5} className="animate-spin" />
) : (
<Truck size={18} strokeWidth={2.5} />
)}
<Truck size={18} strokeWidth={2.5} />
</div>
{!isCollapsed && <h1 className="text-lg font-bold uppercase tracking-tighter text-text">Logicell</h1>}
</div>
Expand All @@ -108,7 +112,7 @@ export const Sidebar = React.memo(({
)}
</NavLink>

<NavLink to="/caixa-de-entrada" onMouseEnter={() => prefetchOperacoes(null)} title={emissaoAntigasPorPasta.inbox ? `${emissaoAntigasPorPasta.inbox} emissão(ões) antiga(s) na Caixa de Entrada` : undefined} className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${isActive ? 'text-primary bg-primary/10 dark:bg-transparent before:absolute before:-left-2 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-5 before:bg-primary before:rounded-r' : 'text-text-muted hover:text-text hover:bg-surface-light'}`}>
<NavLink to="/caixa-de-entrada" onMouseEnter={() => prefetchOperacoes({ pastaId: null })} onMouseLeave={cancelPrefetch} title={emissaoAntigasPorPasta.inbox ? `${emissaoAntigasPorPasta.inbox} emissão(ões) antiga(s) na Caixa de Entrada` : undefined} className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${isActive ? 'text-primary bg-primary/10 dark:bg-transparent before:absolute before:-left-2 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-5 before:bg-primary before:rounded-r' : 'text-text-muted hover:text-text hover:bg-surface-light'}`}>
{({ isActive }) => (
<>
<div className="flex items-center gap-2.5">
Expand All @@ -133,16 +137,18 @@ export const Sidebar = React.memo(({
)}
</NavLink>

<NavLink to="/automacoes" className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${isActive ? 'text-primary bg-primary/10 dark:bg-transparent before:absolute before:-left-2 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-5 before:bg-primary before:rounded-r' : 'text-text-muted hover:text-text hover:bg-surface-light'}`}>
{() => (
<div className="flex items-center gap-2.5">
<Zap size={18} className="shrink-0" />
{!isCollapsed && <span>Automações</span>}
</div>
)}
</NavLink>
{isGestor && (
<NavLink to="/automacoes" className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${isActive ? 'text-primary bg-primary/10 dark:bg-transparent before:absolute before:-left-2 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-5 before:bg-primary before:rounded-r' : 'text-text-muted hover:text-text hover:bg-surface-light'}`}>
{() => (
<div className="flex items-center gap-2.5">
<Zap size={18} className="shrink-0" />
{!isCollapsed && <span>Automações</span>}
</div>
)}
</NavLink>
)}

{user?.app_metadata?.role === "admin" && (
{isAdmin && (
<NavLink to="/admin/usuarios" className={({ isActive }) => `flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all relative ${isActive ? 'text-primary bg-primary/10 dark:bg-transparent before:absolute before:-left-2 before:top-1/2 before:-translate-y-1/2 before:w-1 before:h-5 before:bg-primary before:rounded-r' : 'text-text-muted hover:text-text hover:bg-surface-light'}`}>
{() => (
<div className="flex items-center gap-2.5">
Expand Down Expand Up @@ -218,16 +224,18 @@ export const Sidebar = React.memo(({
{isAddingFolder && !isCollapsed && (
<form onSubmit={handleCreateFolder} className="px-3 mb-2 space-y-2 bg-surface rounded-xl p-2 border border-glass-border">
<input autoFocus value={newFolderName} onChange={e => setNewFolderName(e.target.value)} placeholder="Nome..." className="w-full bg-card-bg dark:bg-bg rounded-lg px-2 py-1 text-xs font-bold outline-none border border-[rgba(0,0,0,0.12)] dark:border-glass-border focus:border-primary text-text placeholder:text-text-dim" />
<select
value={newFolderFaturista}
onChange={e => setNewFolderFaturista(e.target.value)}
className="w-full bg-card-bg dark:bg-bg rounded-lg px-2 py-1 text-xs font-bold outline-none border border-[rgba(0,0,0,0.12)] dark:border-glass-border focus:border-primary text-text"
>
<option value="" disabled>Faturista responsável...</option>
{faturistas.map(f => (
<option key={f.id} value={f.id}>{f.nome || f.email}</option>
))}
</select>
{isGestor && (
<select
value={newFolderFaturista}
onChange={e => setNewFolderFaturista(e.target.value)}
className="w-full bg-card-bg dark:bg-bg rounded-lg px-2 py-1 text-xs font-bold outline-none border border-[rgba(0,0,0,0.12)] dark:border-glass-border focus:border-primary text-text"
>
<option value="">Sem faturista</option>
{faturistas.map(f => (
<option key={f.id} value={f.id}>{f.nome || f.email}</option>
))}
</select>
)}
<div className="flex justify-between items-center px-1">
<div className="flex gap-1.5">
{PRESET_COLORS.map(c => (
Expand All @@ -236,14 +244,14 @@ export const Sidebar = React.memo(({
</div>
<div className="flex gap-1">
<button type="button" onClick={() => setIsAddingFolder(false)} className="p-1 hover:text-rose-500"><X size={14}/></button>
<button type="submit" disabled={isCreating || !newFolderFaturista} className="p-1 hover:text-emerald-500 disabled:opacity-40"><CheckCircle2 size={14}/></button>
<button type="submit" disabled={isCreating} className="p-1 hover:text-emerald-500 disabled:opacity-40"><CheckCircle2 size={14}/></button>
</div>
</div>
</form>
)}

{filteredPastas.map((p: any) => (
<SidebarFolderItem key={p.id} folder={p} isCollapsed={isCollapsed} antigas={emissaoAntigasPorPasta[String(p.id)] ?? 0} />
<SidebarFolderItem key={p.id} folder={p} isCollapsed={isCollapsed} antigas={emissaoAntigasPorPasta[String(p.id)] ?? 0} podeVincularFaturista={isGestor} />
))}

{!isCollapsed && pastas.length > 0 && filteredPastas.length === 0 && (
Expand Down
Loading