feat: topbar de navegação como experimento de UI (v1.7.0)

- Substitui header fixo por topbar com backdrop blur e navegação agrupada em 5 seções
- Adiciona FerramentasDropdown consolidando calculadora e modo privacidade
- NotificationBell expandida com orçamentos e pré-lançamentos
- Remove logout-button, header-dashboard e privacy-mode-toggle como componentes separados
- Logo refatorado com variante compact; topbar com links em lowercase
- Adiciona dependência radix-ui ^1.4.3
- Atualiza CHANGELOG para v1.7.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-24 15:43:14 +00:00
parent af7dd6f737
commit 1b90be6b54
54 changed files with 1492 additions and 787 deletions

View File

@@ -2,17 +2,23 @@
import {
RiAlertFill,
RiArrowRightLine,
RiBankCardLine,
RiBarChart2Line,
RiCheckboxCircleFill,
RiErrorWarningLine,
RiFileListLine,
RiInboxLine,
RiNotification3Line,
RiTimeLine,
} from "@remixicon/react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -27,26 +33,26 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { DashboardNotification } from "@/lib/dashboard/notifications";
import type {
BudgetNotification,
DashboardNotification,
} from "@/lib/dashboard/notifications";
import { cn } from "@/lib/utils/ui";
type NotificationBellProps = {
notifications: DashboardNotification[];
totalCount: number;
budgetNotifications: BudgetNotification[];
preLancamentosCount?: number;
};
function formatDate(dateString: string): string {
// Parse manual para evitar problemas de timezone
// Formato esperado: "YYYY-MM-DD"
const [year, month, day] = dateString.split("-").map(Number);
// Criar data em UTC usando os valores diretos
const date = new Date(Date.UTC(year, month - 1, day));
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
timeZone: "UTC", // Força uso de UTC para evitar conversão de timezone
timeZone: "UTC",
});
}
@@ -57,13 +63,41 @@ function formatCurrency(amount: number): string {
}).format(amount);
}
function SectionLabel({
icon,
title,
}: {
icon: React.ReactNode;
title: string;
}) {
return (
<div className="flex items-center gap-1.5 px-3 pb-1 pt-3">
<span className="text-muted-foreground/60">{icon}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{title}
</span>
</div>
);
}
export function NotificationBell({
notifications,
totalCount,
budgetNotifications,
preLancamentosCount = 0,
}: NotificationBellProps) {
const [open, setOpen] = useState(false);
const displayCount = totalCount > 99 ? "99+" : totalCount.toString();
const hasNotifications = totalCount > 0;
const effectiveTotalCount =
totalCount + preLancamentosCount + budgetNotifications.length;
const displayCount =
effectiveTotalCount > 99 ? "99+" : effectiveTotalCount.toString();
const hasNotifications = effectiveTotalCount > 0;
const invoiceNotifications = notifications.filter(
(n) => n.type === "invoice",
);
const boletoNotifications = notifications.filter((n) => n.type === "boleto");
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -74,7 +108,6 @@ export function NotificationBell({
type="button"
aria-label="Notificações"
aria-expanded={open}
data-has-notifications={hasNotifications}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
@@ -103,23 +136,27 @@ export function NotificationBell({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
Pagamentos para os próximos 5 dias.
Notificações
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
sideOffset={12}
className="w-80 max-h-[500px] overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
>
<DropdownMenuLabel className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b border-border/60 bg-linear-to-b from-background/95 to-background/80 px-4 py-3 text-sm font-semibold">
<span>Notificações | Próximos 5 dias.</span>
{/* Header */}
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold">
<span>Notificações</span>
{hasNotifications && (
<Badge variant="outline" className="text-[10px] font-semibold">
{totalCount} {totalCount === 1 ? "item" : "itens"}
{effectiveTotalCount}{" "}
{effectiveTotalCount === 1 ? "item" : "itens"}
</Badge>
)}
</DropdownMenuLabel>
{notifications.length === 0 ? (
{!hasNotifications ? (
<div className="px-4 py-8">
<Empty>
<EmptyMedia>
@@ -132,71 +169,169 @@ export function NotificationBell({
</Empty>
</div>
) : (
<div className="max-h-[400px] overflow-y-auto py-2">
{notifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className={cn(
"group relative flex flex-col gap-2 rounded-none border-b border-dashed last:border-0 p-2.5",
"cursor-default focus:bg-transparent data-highlighted:bg-accent/60",
)}
>
<div className="flex items-start justify-between w-full gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<div
className={cn(
"flex items-center justify-center text-sm transition-all duration-200",
)}
>
{notification.status === "overdue" ? (
<RiAlertFill color="red" className="size-4" />
) : (
<RiTimeLine className="size-4" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{notification.name}
</span>
<Badge
variant="outline"
className="px-1.5 py-0 tracking-wide text-muted-foreground"
>
{notification.type === "invoice"
? "Cartão"
: "Boleto"}
</Badge>
</div>
<div className="mt-1 flex flex-col items-start gap-2 text-xs text-muted-foreground">
<span className="font-no">
{notification.status === "overdue"
? "Venceu em "
: "Vence em "}
{formatDate(notification.dueDate)}
</span>
<div className="max-h-[460px] overflow-y-auto pb-2">
{/* Pré-lançamentos */}
{preLancamentosCount > 0 && (
<div>
<SectionLabel
icon={<RiInboxLine className="size-3" />}
title="Pré-lançamentos"
/>
<Link
href="/pre-lancamentos"
onClick={() => setOpen(false)}
className="group mx-1 mb-1 flex items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-accent/60"
>
<p className="flex-1 text-xs leading-snug text-foreground">
{preLancamentosCount === 1
? "1 pré-lançamento aguardando revisão"
: `${preLancamentosCount} pré-lançamentos aguardando revisão`}
</p>
<RiArrowRightLine className="size-3 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
)}
{notification.showAmount && notification.amount > 0 && (
<span className="font-medium">
{formatCurrency(notification.amount)}
</span>
{/* Orçamentos */}
{budgetNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBarChart2Line className="size-3" />}
title="Orçamentos"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{budgetNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{n.status === "exceeded" ? (
<RiAlertFill className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "exceeded" ? (
<>
Orçamento de <strong>{n.categoryName}</strong>{" "}
excedido {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)} (
{Math.round(n.usedPercentage)}%)
</>
) : (
<>
<strong>{n.categoryName}</strong> atingiu{" "}
<strong>{Math.round(n.usedPercentage)}%</strong> do
orçamento {" "}
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
{formatCurrency(n.budgetAmount)}
</>
)}
</div>
</p>
</div>
</div>
<Badge
variant={
notification.status === "overdue" ? "destructive" : "info"
}
className={cn("shrink-0 px-2 py-0.5 tracking-wide")}
>
{notification.status === "overdue"
? "Atrasado"
: "Em breve"}
</Badge>
))}
</div>
</DropdownMenuItem>
))}
</div>
)}
{/* Cartão de Crédito */}
{invoiceNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiBankCardLine className="size-3" />}
title="Cartão de Crédito"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{invoiceNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{n.status === "overdue" ? (
<RiAlertFill className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<RiTimeLine className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "overdue" ? (
<>
A fatura de <strong>{n.name}</strong> venceu em{" "}
{formatDate(n.dueDate)}
{n.showAmount && n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}
</>
) : (
<>
A fatura de <strong>{n.name}</strong> vence em{" "}
{formatDate(n.dueDate)}
{n.showAmount && n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}
</>
)}
</p>
</div>
))}
</div>
</div>
)}
{/* Boletos */}
{boletoNotifications.length > 0 && (
<div>
<SectionLabel
icon={<RiFileListLine className="size-3" />}
title="Boletos"
/>
<div className="mx-1 mb-1 overflow-hidden rounded-md">
{boletoNotifications.map((n) => (
<div
key={n.id}
className="flex items-start gap-2 px-2 py-2"
>
{n.status === "overdue" ? (
<RiAlertFill className="mt-0.5 size-3.5 shrink-0 text-destructive" />
) : (
<RiTimeLine className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
)}
<p className="text-xs leading-snug">
{n.status === "overdue" ? (
<>
O boleto <strong>{n.name}</strong>
{n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}{" "}
venceu em {formatDate(n.dueDate)}
</>
) : (
<>
O boleto <strong>{n.name}</strong>
{n.amount > 0 && (
<>
{" "}
<strong>{formatCurrency(n.amount)}</strong>
</>
)}{" "}
vence em {formatDate(n.dueDate)}
</>
)}
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</DropdownMenuContent>