Files
DeskcommCRM/components/shell/Sidebar.tsx
T

403 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useT } from "@/hooks/i18n/useT";
import { usePathname } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { ArrowRight, CaretDoubleLeft, CaretDoubleRight, CaretDown, Gear } from "@/lib/ui/icons";
import { cn } from "@/lib/utils";
import { toggleSidebar } from "@/app/actions/shell/toggleSidebar";
import { useAuth } from "@/hooks/auth/AuthProvider";
import { ConnectionHealthDot } from "@/components/connections/ConnectionHealthDot";
import { VersionFooter } from "@/components/shell/VersionFooter";
import { LogotipoDoProduto, SimboloDoProduto } from "@/components/branding/MarcaDoProduto";
import { marcaEhADoProduto } from "@/lib/branding";
import { useMarcaDaInstalacao } from "@/lib/branding/contexto";
import { GRUPO_NO_RODAPE, sidebarGroups } from "@/lib/navigation/registry";
const CHAVE_GRUPOS_FECHADOS = "sidebar-grupos-fechados";
interface SidebarContentProps {
collapsed: boolean;
showCollapseControl?: boolean;
onNavigate?: () => void;
}
/**
* Navegação principal, agrupada por objetivo.
*
* Não decide nada: `sidebarGroups()` (lib/navigation/registry.ts) resolve quais
* grupos e destinos este papel vê, e este componente desenha. Antes, a lista de
* itens e sete `usePermission()` viviam aqui — e divergiam do hub de
* Configurações e das abas de IA, que mantinham suas próprias listas.
*/
export function SidebarContent({
collapsed,
showCollapseControl = true,
onNavigate,
}: SidebarContentProps) {
// A barra lateral aparece em TODA tela — traduzi-la aqui é o que faz a
// escolha de idioma virar algo visível no primeiro clique.
const t = useT();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const { user, activeOrg } = useAuth();
const todos = sidebarGroups(
user.is_platform_admin && !user.support,
activeOrg?.role ?? null,
activeOrg?.interface_settings,
activeOrg?.modulos_ligados ?? [],
);
// Configurações sai da área que rola e vai para o rodapé fixo: medido em
// 1280x768, ele caía fora da dobra mesmo em telas de 1080px.
const grupos = todos.filter((g) => g.group.id !== GRUPO_NO_RODAPE);
const rodape = todos.find((g) => g.group.id === GRUPO_NO_RODAPE)?.group.hub;
/**
* Grupo fechado é preferência POR NAVEGADOR, não por conta: começa vazio (tudo
* aberto) em toda renderização — servidor, primeira pintura do cliente e nos
* testes, que nunca clicam em nada — e só muda depois do mount, se o
* `localStorage` tiver algo salvo. Guardar o CONJUNTO DOS FECHADOS, e não dos
* abertos, é o que faz "sem preferência salva" já significar "tudo aberto".
*/
const [gruposFechados, setGruposFechados] = useState<Set<string>>(() => new Set());
useEffect(() => {
try {
const salvo = window.localStorage.getItem(CHAVE_GRUPOS_FECHADOS);
if (salvo) setGruposFechados(new Set(JSON.parse(salvo) as string[]));
} catch {
// Storage bloqueado (aba privada) — fica tudo aberto, que é o padrão.
}
}, []);
function toggleGrupo(id: string) {
setGruposFechados((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
try {
window.localStorage.setItem(CHAVE_GRUPOS_FECHADOS, JSON.stringify([...next]));
} catch {
// Clique continua funcionando nesta sessão; só não sobrevive a um F5.
}
return next;
});
}
const brand = useMarcaDaInstalacao();
/**
* O CONSUMIDOR do nome por organização.
*
* Sem ele, `settings.branding.app_name` seria campo decorativo: medido, o nome
* da org não aparece em lugar nenhum da casca para o cliente típico de um
* revendedor — o único leitor é o `TenantSwitcher`, e ele devolve `null` com
* uma organização só.
*
* A marca da INSTALAÇÃO continua embaixo: a organização que não definiu nome
* vê exatamente o que via antes. O que mudou é POR ONDE ela chega — era
* `branding()`, que no navegador lê `window.__PUBLIC_ENV__` e no servidor lê
* `process.env`, e essas duas fontes passaram a divergir quando o layout raiz
* começou a injetar a marca do BANCO. Divergência entre SSR e cliente aqui não
* é detalhe: com logo no banco e `APP_LOGO_URL` vazio, o servidor desenhava o
* `<span>` de baixo e o cliente desenhava o `<img>` — React #418 em toda tela.
* Hoje a marca vem por PROP do servidor (`useMarcaDaInstalacao`), pela mesma
* rota de `activeOrg`, e os dois lados leem o mesmo objeto por construção.
*/
const nome = activeOrg?.marca?.nome ?? brand.name;
/**
* O mesmo desenho para o LOGO — e é este par de linhas que fecha o caminho do
* `logo_url` gravado até a tela.
*
* `||` e não `??`: vazio é AUSÊNCIA de logo, não "logo em branco". É a regra
* que `resolveBranding` e `primeiroDefinido` já aplicam nas camadas de baixo, e
* com `??` um `""` vindo de cima apagaria o logo do revendedor em vez de
* descer para ele — que é o contrário do que a precedência por campo promete.
*/
const logo = activeOrg?.marca?.logoUrl || brand.logoUrl;
const logoEscuro =
activeOrg?.marca?.logoDarkUrl !== undefined
? activeOrg.marca.logoDarkUrl
: activeOrg?.marca?.logoUrl
? null
: brand.logoDarkUrl;
// Só quando NINGUÉM — nem a instalação, nem a organização — pôs marca própria:
// é a condição de `lib/branding.ts`, avaliada sobre o que a barra vai mostrar.
const marcaDoProduto = marcaEhADoProduto({ name: nome, logoUrl: logo ?? null });
return (
<>
<div
className={cn(
"flex h-14 items-center border-b px-4",
collapsed ? "justify-center" : "justify-start",
)}
>
{(logo || logoEscuro) && !collapsed ? (
// Sem arte própria para o escuro, preserva a proteção de contraste.
<div
className={cn(
"rounded-md",
!logoEscuro && "dark:bg-white dark:px-2 dark:py-1 dark:shadow-sm",
)}
>
{/* <img> em vez de next/image de propósito: a URL vem de quem hospeda
(banco ou .env), e next/image exige allowlist de domínios fechada em
build — a imagem pré-buildada rejeitaria o domínio do self-hoster.
Altura fixa e largura livre porque a arte enviada tem proporção
desconhecida; forçar as duas distorceria o logo de quem configurou. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{logo ? (
<img
src={logo}
alt={nome}
className={cn(
"h-7 w-auto max-w-[10rem] object-contain",
logoEscuro && "dark:hidden",
)}
/>
) : (
<span className="dark:hidden">{nome}</span>
)}
{logoEscuro ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoEscuro}
alt={nome}
className="hidden h-7 w-auto max-w-[10rem] object-contain dark:block"
/>
) : null}
</div>
) : marcaDoProduto ? (
// O desenho do produto, inline (ver `components/branding/MarcaDoProduto.tsx`):
// logotipo com a barra aberta, só o símbolo com ela recolhida.
collapsed ? (
<SimboloDoProduto nome={nome} className="h-8 w-8" />
) : (
<LogotipoDoProduto nome={nome} className="h-8 w-auto" />
)
) : (
<span className={cn("font-semibold tracking-tight", collapsed && "sr-only")}>{nome}</span>
)}
{collapsed && !marcaDoProduto && (
<span aria-hidden className="text-lg font-bold text-primary">
{/* Spread e não `[0]`: nome começando com emoji ou acento composto
quebraria no meio do code point. Mesma regra de `resolveBranding`
— a inicial precisa acompanhar o nome que a barra mostra, senão
recolher o menu troca a marca. */}
{[...nome][0]?.toUpperCase() ?? brand.initial}
</span>
)}
</div>
{/*
A DENSIDADE É MEDIDA, NÃO ESTÉTICA.
O e2e `navegacao.spec.ts` exige que o menu inteiro caiba em 1280×900 sem
rolar — porque um grupo abaixo da dobra é indistinguível de um grupo que
não existe. Com 18 links a margem era de ~4px: a tela nova de Produtos
estourou a dobra por uma linha, e reprovou no CI.
`py-1.5` → `py-1` (linha de 32px para 28px) e o intervalo entre grupos de
12px para 8px devolvem ~90px — folga para o próximo item, em vez de
deixar a próxima tela nova repetir esta corrida.
⚠️ Isto é remendo de densidade, não conserto estrutural. O menu vai
estourar de novo: a saída existente é o HUB (o grupo IA já a usa — nove
das treze telas dele moram atrás do "Ver tudo em IA"), e o CRM ainda não
tem um. Quando o quinto destino de CRM aparecer, é hub que se cria, não
mais 4px que se raspa.
✅ O QUINTO APARECEU, e a promessa foi paga. Tarefas (PR #546) levou o
CRM a cinco telas e a dobra estourou em 13px — medido em 1280×900,
`scrollHeight` 776 contra 763 de altura. O conserto foi `/app/crm`, o
hub do grupo: Produtos e Etapas do funil saíram do menu para dentro
dele, e nenhum valor deste arquivo mudou por causa disso.
Fica valendo o mesmo, agora para o próximo grupo: com hub em CRM, IA e
Organização, tela nova de qualquer um dos três não pressiona mais o
menu. Quem pressionar é um grupo SEM hub — Atendimento (4), Canais (3)
ou Análise (3). Quando um deles passar de quatro, a resposta é a mesma:
cria-se o hub, não se raspa densidade.
✅ ANÁLISE FOI A SEGUINTE, e a regra valeu igual. Atividades (PR #583)
levou o grupo a cinco telas e a dobra estourou de novo — medido em
1280×900, logado como admin: `scrollHeight` 776 contra 763 de altura
visível, 13px de excesso, com o link "Audit Log" 13px abaixo da caixa de
conteúdo da nav. O conserto foi `/app/analise`, o hub do grupo: Evolução
da IA e Audit Log saíram do menu para dentro dele, e NENHUM valor deste
arquivo mudou por causa disso. Sobrou 19px de folga — a mesma que havia
antes de Atividades chegar.
Ficam sem hub Atendimento e Canais (4 e 2 destinos quando isto foi
medido) — em qualquer um deles, o quinto destino é que cria o hub, nunca
mais densidade raspada. A conta é fechada e vale conferir antes de abrir
o PR: cada linha custa 32px (28px de altura + 4px de `space-y-1`), e
trocar N destinos do menu por um único link de hub devolve (N-1)×32px.
*/}
<nav className="flex-1 space-y-2 overflow-y-auto p-2" aria-label={t("Navegação principal")}>
{grupos.map(({ group, items }) => {
const tituloId = `nav-grupo-${group.id}`;
// Recolhido o sidebar inteiro (rail de 64px), o grupo sempre mostra
// seus itens — não há onde desenhar cabeçalho nem seta para fechá-lo.
const aberto = collapsed || !gruposFechados.has(group.id);
return (
<div key={group.id} className="space-y-1">
{/* Colapsado, o sidebar tem 64px: seis rótulos ali seriam ilegíveis.
Vira um filete separador, que preserva o agrupamento sem texto. */}
{collapsed ? (
<div aria-hidden className="mx-2 border-t first:hidden" />
) : (
<h2 id={tituloId}>
<button
type="button"
onClick={() => toggleGrupo(group.id)}
aria-expanded={aberto}
className="flex w-full items-center justify-between rounded-md px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
>
{t(group.label)}
<CaretDown
size={12}
weight="bold"
className={cn(
"shrink-0 text-text-subtle transition-transform",
!aberto && "-rotate-90",
)}
aria-hidden
/>
</button>
</h2>
)}
{aberto && (
<ul
aria-labelledby={collapsed ? undefined : tituloId}
aria-label={collapsed ? t(group.label) : undefined}
className="space-y-1"
>
{items.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
title={collapsed ? t(item.label) : undefined}
aria-current={isActive ? "page" : undefined}
onClick={onNavigate}
className={cn(
"relative flex items-center gap-3 rounded-md px-3 py-1 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
collapsed && "justify-center px-2",
)}
>
<Icon size={18} weight={isActive ? "fill" : "regular"} aria-hidden />
{!collapsed && <span className="truncate">{t(item.label)}</span>}
{item.healthDot && (
<ConnectionHealthDot
className={cn(collapsed ? "absolute top-1.5 right-1.5" : "ml-auto")}
/>
)}
</Link>
</li>
);
})}
{group.hub && (
<li>
<Link
href={group.hub.href}
title={collapsed ? t(group.hub.label) : undefined}
aria-current={pathname === group.hub.href ? "page" : undefined}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-1 text-sm transition-colors",
pathname === group.hub.href
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
collapsed && "justify-center px-2",
)}
>
<ArrowRight size={18} aria-hidden />
{!collapsed && <span className="truncate">{t(group.hub.label)}</span>}
</Link>
</li>
)}
</ul>
)}
</div>
);
})}
</nav>
<div className="border-t p-2">
{rodape && (
<Link
href={rodape.href}
title={collapsed ? t(rodape.label) : undefined}
aria-current={pathname.startsWith(rodape.href) ? "page" : undefined}
onClick={onNavigate}
className={cn(
"mb-1 flex items-center gap-3 rounded-md px-3 py-1 text-sm transition-colors",
pathname.startsWith(rodape.href)
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
collapsed && "justify-center px-2",
)}
>
<Gear size={18} aria-hidden />
{!collapsed && <span className="truncate">{t(rodape.label)}</span>}
</Link>
)}
<VersionFooter collapsed={collapsed} onNavigate={onNavigate} />
{showCollapseControl && (
<button
type="button"
onClick={() => startTransition(() => toggleSidebar(collapsed))}
disabled={isPending}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-xs text-muted-foreground hover:bg-accent/50 hover:text-foreground",
collapsed && "justify-center px-2",
)}
aria-label={collapsed ? t("Expandir sidebar") : t("Recolher sidebar")}
>
{collapsed ? (
<CaretDoubleRight size={14} aria-hidden />
) : (
<CaretDoubleLeft size={14} aria-hidden />
)}
{!collapsed && <span>{t("Recolher")}</span>}
</button>
)}
</div>
</>
);
}
export function Sidebar({ collapsed }: { collapsed: boolean }) {
return (
<aside
className={cn(
// ⚠️ `sticky`, e NUNCA `fixed`.
//
// Com `fixed` a barra sai do fluxo: ela não ocupa lugar nenhum na linha,
// e quem afastava o conteúdo era um `md:ml-16`/`md:ml-60` do lado de lá.
// Duas medidas para a mesma coisa, em componentes diferentes — e no dia
// em que discordassem (largura de 60 com margem de 16), a barra passava
// POR CIMA da lista de conversas, escondendo o começo de cada linha.
//
// Foi assim que apareceu numa instalação real: a barra expandida, com as
// etiquetas legíveis, e a lista atrás dela cortada. Um F5 "consertava",
// que é a assinatura de servidor e navegador terem pintado estados
// diferentes — e `AppShell` e `Sidebar` são ambos `"use client"`.
//
// `sticky top-0 h-screen` dá o mesmo efeito visual (a barra não rola com
// a página) e ela VOLTA a ocupar lugar: sobra para o conteúdo exatamente
// o que ela não usou, e não há segunda medida para discordar.
//
// `shrink-0` porque item de flex encolhe por padrão, e uma barra de 60
// espremida para caber é o mesmo defeito por outro caminho.
"sticky top-0 z-30 flex h-screen shrink-0 flex-col border-r bg-card transition-[width] duration-200",
collapsed ? "w-16" : "w-60",
)}
>
<SidebarContent collapsed={collapsed} />
</aside>
);
}