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:
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleLogOut() {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login");
|
||||
},
|
||||
onRequest: (_ctx) => {
|
||||
setLoading(true);
|
||||
},
|
||||
onResponse: (_ctx) => {
|
||||
setLoading(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
aria-busy={loading}
|
||||
data-loading={loading}
|
||||
onClick={handleLogOut}
|
||||
disabled={loading}
|
||||
className="text-destructive transition-all duration-200 border hover:text-destructive focus-visible:ring-destructive/30 data-[loading=true]:opacity-90"
|
||||
>
|
||||
{loading && <Spinner className="size-3.5 text-destructive" />}
|
||||
<span aria-live="polite">{loading ? "Saindo" : "Sair"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
Encerrar sessão
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type CalculatorDialogButtonProps = {
|
||||
onSelectValue?: (value: string) => void;
|
||||
};
|
||||
|
||||
function CalculatorDialogContent({
|
||||
export function CalculatorDialogContent({
|
||||
open,
|
||||
onSelectValue,
|
||||
}: {
|
||||
|
||||
@@ -158,7 +158,7 @@ export function CardItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="flex p-6 h-[300px] w-[440px]">
|
||||
<Card className="flex flex-col p-6 w-full">
|
||||
<CardHeader className="space-y-2 px-0 pb-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
@@ -209,14 +209,14 @@ export function CardItem({
|
||||
</div>
|
||||
|
||||
{brandAsset ? (
|
||||
<div className="flex items-center justify-center rounded-lg py-1">
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Image
|
||||
src={brandAsset}
|
||||
alt={`Bandeira ${brand}`}
|
||||
width={42}
|
||||
height={42}
|
||||
width={36}
|
||||
height={36}
|
||||
className={cn(
|
||||
"h-6 w-auto rounded-full",
|
||||
"h-5 w-auto rounded",
|
||||
isInactive && "grayscale opacity-40",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function CardsPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { main_font } from "@/public/fonts/font_index";
|
||||
import MagnetLines from "../magnet-lines";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
@@ -54,9 +53,7 @@ export function DashboardWelcome({
|
||||
const greeting = getGreeting();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`${main_font.className} relative px-6 py-12 bg-welcome-banner border-none shadow-none overflow-hidden`}
|
||||
>
|
||||
<Card className="relative px-6 py-12 bg-welcome-banner overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
|
||||
<MagnetLines
|
||||
rows={8}
|
||||
|
||||
@@ -270,7 +270,7 @@ export function InvoiceSummaryCard({
|
||||
alt={`Bandeira ${cardBrand}`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-5 w-auto rounded-full"
|
||||
className="h-5 w-auto rounded"
|
||||
/>
|
||||
<span className="truncate">{cardBrand}</span>
|
||||
</div>
|
||||
|
||||
@@ -64,14 +64,74 @@ const feedbackCategories = [
|
||||
},
|
||||
];
|
||||
|
||||
export function FeedbackDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
export function FeedbackDialogBody({ onClose }: { onClose?: () => void }) {
|
||||
const handleCategoryClick = (url: string) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setOpen(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiMessageLine className="h-5 w-5" />
|
||||
Enviar Feedback
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sua opinião é muito importante! Escolha o tipo de feedback que
|
||||
deseja compartilhar.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-4">
|
||||
{feedbackCategories.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleCategoryClick(item.url)}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-lg border transition-all",
|
||||
"hover:border-primary hover:bg-accent/50",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
||||
"bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", item.color)} />
|
||||
</div>
|
||||
<div className="flex-1 text-left space-y-1">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
{item.title}
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg text-xs text-muted-foreground">
|
||||
<RiExternalLinkLine className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Você será redirecionado para o GitHub Discussions onde poderá
|
||||
escrever seu feedback. É necessário ter uma conta no GitHub.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedbackDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
@@ -93,62 +153,7 @@ export function FeedbackDialog() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Enviar Feedback</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiMessageLine className="h-5 w-5" />
|
||||
Enviar Feedback
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sua opinião é muito importante! Escolha o tipo de feedback que
|
||||
deseja compartilhar.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-4">
|
||||
{feedbackCategories.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleCategoryClick(item.url)}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-lg border transition-all",
|
||||
"hover:border-primary hover:bg-accent/50",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
||||
"bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", item.color)} />
|
||||
</div>
|
||||
<div className="flex-1 text-left space-y-1">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
{item.title}
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 rounded-lg text-xs text-muted-foreground">
|
||||
<RiExternalLinkLine className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Você será redirecionado para o GitHub Discussions onde poderá
|
||||
escrever seu feedback. É necessário ter uma conta no GitHub.
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<FeedbackDialogBody onClose={() => setOpen(false)} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { NotificationBell } from "@/components/notificacoes/notification-bell";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
|
||||
import { AnimatedThemeToggler } from "./animated-theme-toggler";
|
||||
import LogoutButton from "./auth/logout-button";
|
||||
import { CalculatorDialogButton } from "./calculadora/calculator-dialog";
|
||||
import { PrivacyModeToggle } from "./privacy-mode-toggle";
|
||||
import { RefreshPageButton } from "./refresh-page-button";
|
||||
|
||||
type SiteHeaderProps = {
|
||||
notificationsSnapshot: DashboardNotificationsSnapshot;
|
||||
};
|
||||
|
||||
export async function SiteHeader({ notificationsSnapshot }: SiteHeaderProps) {
|
||||
const _user = await getUser();
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 border-b bg-background md:static md:z-auto flex h-12 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<NotificationBell
|
||||
notifications={notificationsSnapshot.notifications}
|
||||
totalCount={notificationsSnapshot.totalCount}
|
||||
/>
|
||||
<CalculatorDialogButton withTooltip />
|
||||
<RefreshPageButton />
|
||||
<PrivacyModeToggle />
|
||||
<AnimatedThemeToggler />
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<FeedbackDialog />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function PaymentMethodSection({
|
||||
return (
|
||||
<>
|
||||
{!isUpdateMode ? (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row mt-3">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
|
||||
@@ -41,9 +41,9 @@ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="w-72">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Logo />
|
||||
<SheetHeader className="border-b pb-4">
|
||||
<SheetTitle asChild>
|
||||
<Logo variant="compact" />
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils/ui";
|
||||
import { version } from "@/package.json";
|
||||
|
||||
interface LogoProps {
|
||||
variant?: "full" | "small";
|
||||
variant?: "full" | "small" | "compact";
|
||||
className?: string;
|
||||
showVersion?: boolean;
|
||||
}
|
||||
@@ -13,6 +13,29 @@ export function Logo({
|
||||
className,
|
||||
showVersion = false,
|
||||
}: LogoProps) {
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<Image
|
||||
src="/logo_small.png"
|
||||
alt="OpenMonetis"
|
||||
width={32}
|
||||
height={32}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
<Image
|
||||
src="/logo_text.png"
|
||||
alt="OpenMonetis"
|
||||
width={110}
|
||||
height={32}
|
||||
className="object-contain dark:invert hidden sm:block"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "small") {
|
||||
return (
|
||||
<Image
|
||||
@@ -45,8 +68,8 @@ export function Logo({
|
||||
priority
|
||||
/>
|
||||
{showVersion && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
v{version}
|
||||
<span className="text-[9px] font-medium text-muted-foreground">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function MonthNavigation() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full flex-row bg-card text-card-foreground p-4">
|
||||
<Card className="w-full flex-row bg-card text-card-foreground p-4 sticky top-14 z-10">
|
||||
<div className="flex items-center gap-1">
|
||||
<NavigationButton
|
||||
direction="left"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RiEyeLine, RiEyeOffLine } from "@remixicon/react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { usePrivacyMode } from "./privacy-provider";
|
||||
|
||||
type PrivacyModeToggleProps = React.ComponentPropsWithoutRef<"button">;
|
||||
|
||||
export const PrivacyModeToggle = ({
|
||||
className,
|
||||
...props
|
||||
}: PrivacyModeToggleProps) => {
|
||||
const { privacyMode, toggle } = usePrivacyMode();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={undefined}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={privacyMode}
|
||||
aria-label={
|
||||
privacyMode
|
||||
? "Desativar modo privacidade"
|
||||
: "Ativar modo privacidade"
|
||||
}
|
||||
title={
|
||||
privacyMode
|
||||
? "Desativar modo privacidade"
|
||||
: "Ativar modo privacidade"
|
||||
}
|
||||
data-state={privacyMode ? "active" : "inactive"}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"group relative text-muted-foreground transition-all duration-200",
|
||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
"data-[state=active]:bg-accent/60 data-[state=active]:text-foreground border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=active]:opacity-100"
|
||||
>
|
||||
<span className="absolute inset-0 bg-linear-to-br from-blue-500/5 via-transparent to-blue-500/15 dark:from-blue-500/10 dark:to-blue-500/30" />
|
||||
</span>
|
||||
{privacyMode ? (
|
||||
<RiEyeOffLine
|
||||
className="size-4 transition-transform duration-200"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<RiEyeLine
|
||||
className="size-4 transition-transform duration-200"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{privacyMode
|
||||
? "Modo privacidade ativo"
|
||||
: "Modo privacidade inativo"}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
{privacyMode ? "Desativar privacidade" : "Ativar privacidade"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -45,7 +45,10 @@ export function RefreshPageButton({
|
||||
{...props}
|
||||
>
|
||||
<RiRefreshLine
|
||||
className={cn("size-4 transition-transform duration-200", isPending && "animate-spin")}
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
isPending && "animate-spin",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,34 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { RiPieChartLine } from "@remixicon/react";
|
||||
import { useMemo } from "react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
type TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||
import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
|
||||
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
|
||||
|
||||
function AreaTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const items = payload
|
||||
.filter((entry) => Number(entry.value) > 0)
|
||||
.sort((a, b) => Number(b.value) - Number(a.value));
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="min-w-[210px] rounded-lg border border-border/50 bg-background px-3 py-2.5 shadow-xl">
|
||||
<p className="mb-2.5 border-b border-border/50 pb-1.5 text-xs font-semibold text-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((entry) => (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center justify-between gap-6"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-foreground">
|
||||
{currencyFormatter.format(Number(entry.value))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategoryReportChartProps {
|
||||
data: CategoryChartData;
|
||||
}
|
||||
|
||||
const CHART_COLORS = CATEGORY_COLORS;
|
||||
const LIMIT_OPTIONS = [
|
||||
{ value: "5", label: "Top 5" },
|
||||
{ value: "10", label: "Top 10" },
|
||||
{ value: "15", label: "Top 15" },
|
||||
] as const;
|
||||
|
||||
const MAX_CATEGORIES_IN_CHART = 15;
|
||||
const MAX_CATEGORIES = 15;
|
||||
|
||||
export function CategoryReportChart({ data }: CategoryReportChartProps) {
|
||||
const { chartData, categories } = data;
|
||||
const [limit, setLimit] = React.useState("10");
|
||||
|
||||
const { topCategories, filteredChartData } = React.useMemo(() => {
|
||||
const limitNum = Math.min(Number(limit), MAX_CATEGORIES);
|
||||
|
||||
const categoriesWithTotal = categories.map((category) => ({
|
||||
...category,
|
||||
total: chartData.reduce((sum, point) => {
|
||||
const v = point[category.name];
|
||||
return sum + (typeof v === "number" ? v : 0);
|
||||
}, 0),
|
||||
}));
|
||||
|
||||
const sorted = categoriesWithTotal
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, limitNum);
|
||||
|
||||
const filtered = chartData.map((point) => {
|
||||
const result: { month: string; [key: string]: number | string } = {
|
||||
month: point.month,
|
||||
};
|
||||
for (const cat of sorted) {
|
||||
result[cat.name] = (point[cat.name] as number) ?? 0;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
return { topCategories: sorted, filteredChartData: filtered };
|
||||
}, [categories, chartData, limit]);
|
||||
|
||||
const chartConfig = React.useMemo<ChartConfig>(() => {
|
||||
const config: ChartConfig = {};
|
||||
for (let i = 0; i < topCategories.length; i++) {
|
||||
const cat = topCategories[i];
|
||||
config[cat.name] = {
|
||||
label: cat.name,
|
||||
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
||||
};
|
||||
}
|
||||
return config;
|
||||
}, [topCategories]);
|
||||
|
||||
// Check if there's no data
|
||||
if (categories.length === 0 || chartData.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -40,165 +141,91 @@ export function CategoryReportChart({ data }: CategoryReportChartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get top 10 categories by total spending
|
||||
const { topCategories, filteredChartData } = useMemo(() => {
|
||||
// Calculate total for each category across all periods
|
||||
const categoriesWithTotal = categories.map((category) => {
|
||||
const total = chartData.reduce((sum, dataPoint) => {
|
||||
const value = dataPoint[category.name];
|
||||
return sum + (typeof value === "number" ? value : 0);
|
||||
}, 0);
|
||||
|
||||
return { ...category, total };
|
||||
});
|
||||
|
||||
// Sort by total (descending) and take top 10
|
||||
const sorted = categoriesWithTotal
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, MAX_CATEGORIES_IN_CHART);
|
||||
|
||||
// Filter chartData to include only top categories
|
||||
const _topCategoryNames = new Set(sorted.map((cat) => cat.name));
|
||||
const filtered = chartData.map((dataPoint) => {
|
||||
const filteredPoint: { month: string; [key: string]: number | string } = {
|
||||
month: dataPoint.month,
|
||||
};
|
||||
|
||||
// Only include data for top categories
|
||||
for (const cat of sorted) {
|
||||
if (dataPoint[cat.name] !== undefined) {
|
||||
filteredPoint[cat.name] = dataPoint[cat.name];
|
||||
}
|
||||
}
|
||||
|
||||
return filteredPoint;
|
||||
});
|
||||
|
||||
return { topCategories: sorted, filteredChartData: filtered };
|
||||
}, [categories, chartData]);
|
||||
const firstMonth = chartData[0]?.month ?? "";
|
||||
const lastMonth = chartData[chartData.length - 1]?.month ?? "";
|
||||
const periodLabel =
|
||||
firstMonth === lastMonth ? firstMonth : `${firstMonth} – ${lastMonth}`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Evolução por Categoria - Top {topCategories.length}
|
||||
</CardTitle>
|
||||
<Card className="pt-0">
|
||||
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
|
||||
<div className="grid flex-1 gap-1">
|
||||
<CardTitle>Evolução por Categoria</CardTitle>
|
||||
<CardDescription>{periodLabel}</CardDescription>
|
||||
</div>
|
||||
<Select value={limit} onValueChange={setLimit}>
|
||||
<SelectTrigger
|
||||
className="hidden w-[130px] rounded-lg sm:ml-auto sm:flex"
|
||||
aria-label="Número de categorias"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
{LIMIT_OPTIONS.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="rounded-lg"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={filteredChartData}>
|
||||
<defs>
|
||||
{topCategories.map((category, index) => {
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
return (
|
||||
<linearGradient
|
||||
key={category.id}
|
||||
id={`gradient-${category.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
className="text-xs"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||||
tickFormatter={(value) => {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(0)}k`;
|
||||
}
|
||||
return value.toString();
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<div className="mb-2 font-semibold">
|
||||
{payload[0]?.payload?.month}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{payload.map((entry, index) => {
|
||||
if (entry.dataKey === "month") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-4 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{currencyFormatter.format(
|
||||
Number(entry.value) || 0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{topCategories.map((category, index) => {
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[300px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredChartData}>
|
||||
<defs>
|
||||
{topCategories.map((cat, index) => {
|
||||
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
|
||||
return (
|
||||
<Area
|
||||
key={category.id}
|
||||
type="monotone"
|
||||
dataKey={category.name}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#gradient-${category.id})`}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<linearGradient
|
||||
key={cat.id}
|
||||
id={`fill-${cat.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</defs>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex flex-wrap gap-4">
|
||||
{topCategories.map((category, index) => {
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
return (
|
||||
<div key={category.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{category.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
/>
|
||||
|
||||
<ChartTooltip cursor={false} content={<AreaTooltip />} />
|
||||
|
||||
{topCategories.map((cat, index) => (
|
||||
<Area
|
||||
key={cat.id}
|
||||
dataKey={cat.name}
|
||||
type="natural"
|
||||
fill={`url(#fill-${cat.id})`}
|
||||
stroke={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
|
||||
strokeWidth={1.5}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
|
||||
import { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
import { NotificationBell } from "@/components/notificacoes/notification-bell";
|
||||
import { PrivacyModeToggle } from "@/components/privacy-mode-toggle";
|
||||
import { RefreshPageButton } from "@/components/refresh-page-button";
|
||||
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
|
||||
import { Logo } from "../logo";
|
||||
import { TopNavMenu } from "./top-nav-menu";
|
||||
import { TopbarUser } from "./topbar-user";
|
||||
|
||||
@@ -29,49 +26,26 @@ export function AppTopbar({
|
||||
notificationsSnapshot,
|
||||
}: AppTopbarProps) {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-card h-14 shrink-0 flex items-center shadow-xs">
|
||||
<header className="fixed top-0 left-0 right-0 z-50 h-15 shrink-0 flex items-center bg-card/80 backdrop-blur-md supports-backdrop-filter:bg-card/70">
|
||||
<div className="w-full max-w-8xl mx-auto px-4 flex items-center gap-4 h-full">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-2 shrink-0 mr-1"
|
||||
>
|
||||
<Image
|
||||
src="/logo_small.png"
|
||||
alt="OpenMonetis"
|
||||
width={28}
|
||||
height={28}
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
<Image
|
||||
src="/logo_text.png"
|
||||
alt="OpenMonetis"
|
||||
width={90}
|
||||
height={28}
|
||||
className="object-contain dark:invert hidden sm:block"
|
||||
priority
|
||||
/>
|
||||
<Link href="/dashboard" className="shrink-0 mr-1">
|
||||
<Logo variant="compact" />
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<TopNavMenu preLancamentosCount={preLancamentosCount} />
|
||||
<TopNavMenu />
|
||||
|
||||
{/* Right-side actions */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<NotificationBell
|
||||
notifications={notificationsSnapshot.notifications}
|
||||
totalCount={notificationsSnapshot.totalCount}
|
||||
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
||||
preLancamentosCount={preLancamentosCount}
|
||||
/>
|
||||
<CalculatorDialogButton withTooltip />
|
||||
<RefreshPageButton />
|
||||
<PrivacyModeToggle />
|
||||
<AnimatedThemeToggler />
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-5 w-px bg-foreground/20 mx-1 hidden sm:block"
|
||||
/>
|
||||
<FeedbackDialog />
|
||||
</div>
|
||||
|
||||
{/* User avatar */}
|
||||
|
||||
105
components/topbar/ferramentas-dropdown.tsx
Normal file
105
components/topbar/ferramentas-dropdown.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { RiCalculatorLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { CalculatorDialogContent } from "@/components/calculadora/calculator-dialog";
|
||||
import { usePrivacyMode } from "@/components/privacy-provider";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
const itemClass =
|
||||
"flex w-full items-center gap-2.5 rounded-sm px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors cursor-pointer";
|
||||
|
||||
export function FerramentasDropdownContent() {
|
||||
const { privacyMode, toggle } = usePrivacyMode();
|
||||
const [calcOpen, setCalcOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={calcOpen} onOpenChange={setCalcOpen}>
|
||||
<ul className="grid w-52 gap-0.5 p-2">
|
||||
<li>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className={cn(itemClass)}>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
<RiCalculatorLine className="size-4" />
|
||||
</span>
|
||||
<span className="flex-1 text-left">calculadora</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" onClick={toggle} className={cn(itemClass)}>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{privacyMode ? (
|
||||
<RiEyeOffLine className="size-4" />
|
||||
) : (
|
||||
<RiEyeLine className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 text-left">privacidade</span>
|
||||
{privacyMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
Ativo
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<CalculatorDialogContent open={calcOpen} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileFerramentasItemsProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function MobileFerramentasItems({
|
||||
onClose,
|
||||
}: MobileFerramentasItemsProps) {
|
||||
const { privacyMode, toggle } = usePrivacyMode();
|
||||
const [calcOpen, setCalcOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={calcOpen} onOpenChange={setCalcOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
<RiCalculatorLine className="size-4" />
|
||||
</span>
|
||||
<span className="flex-1 text-left">calculadora</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggle();
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{privacyMode ? (
|
||||
<RiEyeOffLine className="size-4" />
|
||||
) : (
|
||||
<RiEyeLine className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 text-left">privacidade</span>
|
||||
{privacyMode && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
|
||||
Ativo
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
<CalculatorDialogContent open={calcOpen} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -33,33 +33,32 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import type { DropdownLinkItem } from "./dropdown-link-list";
|
||||
import { DropdownLinkList } from "./dropdown-link-list";
|
||||
import {
|
||||
FerramentasDropdownContent,
|
||||
MobileFerramentasItems,
|
||||
} from "./ferramentas-dropdown";
|
||||
import { MobileNavLink, MobileSectionLabel } from "./mobile-nav-link";
|
||||
import { triggerClass } from "./nav-styles";
|
||||
import { SimpleNavLink } from "./simple-nav-link";
|
||||
|
||||
type TopNavMenuProps = {
|
||||
preLancamentosCount?: number;
|
||||
};
|
||||
|
||||
export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
export function TopNavMenu() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const close = () => setSheetOpen(false);
|
||||
|
||||
const lancamentosItems: DropdownLinkItem[] = [
|
||||
{
|
||||
href: "/lancamentos",
|
||||
label: "Lançamentos",
|
||||
label: "lançamentos",
|
||||
icon: <RiArrowLeftRightLine className="size-4" />,
|
||||
},
|
||||
{
|
||||
href: "/pre-lancamentos",
|
||||
label: "Pré-Lançamentos",
|
||||
label: "pré-lançamentos",
|
||||
icon: <RiInboxLine className="size-4" />,
|
||||
badge: preLancamentosCount,
|
||||
},
|
||||
{
|
||||
href: "/calendario",
|
||||
label: "Calendário",
|
||||
label: "calendário",
|
||||
icon: <RiCalendarEventLine className="size-4" />,
|
||||
},
|
||||
];
|
||||
@@ -67,17 +66,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
const financasItems: DropdownLinkItem[] = [
|
||||
{
|
||||
href: "/cartoes",
|
||||
label: "Cartões",
|
||||
label: "cartões",
|
||||
icon: <RiBankCard2Line className="size-4" />,
|
||||
},
|
||||
{
|
||||
href: "/contas",
|
||||
label: "Contas",
|
||||
label: "contas",
|
||||
icon: <RiBankLine className="size-4" />,
|
||||
},
|
||||
{
|
||||
href: "/orcamentos",
|
||||
label: "Orçamentos",
|
||||
label: "orçamentos",
|
||||
icon: <RiFundsLine className="size-4" />,
|
||||
},
|
||||
];
|
||||
@@ -85,17 +84,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
const organizacaoItems: DropdownLinkItem[] = [
|
||||
{
|
||||
href: "/pagadores",
|
||||
label: "Pagadores",
|
||||
label: "pagadores",
|
||||
icon: <RiGroupLine className="size-4" />,
|
||||
},
|
||||
{
|
||||
href: "/categorias",
|
||||
label: "Categorias",
|
||||
label: "categorias",
|
||||
icon: <RiPriceTag3Line className="size-4" />,
|
||||
},
|
||||
{
|
||||
href: "/anotacoes",
|
||||
label: "Anotações",
|
||||
label: "anotações",
|
||||
icon: <RiTodoLine className="size-4" />,
|
||||
},
|
||||
];
|
||||
@@ -103,17 +102,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
const analiseItems: DropdownLinkItem[] = [
|
||||
{
|
||||
href: "/insights",
|
||||
label: "Insights",
|
||||
label: "insights",
|
||||
icon: <RiSparklingLine className="size-4" />,
|
||||
},
|
||||
{
|
||||
href: "/relatorios/tendencias",
|
||||
label: "Tendências",
|
||||
label: "tendências",
|
||||
icon: <RiFileChartLine className="size-4" />,
|
||||
},
|
||||
{
|
||||
href: "/relatorios/uso-cartoes",
|
||||
label: "Uso de Cartões",
|
||||
label: "uso de cartões",
|
||||
icon: <RiBankCard2Line className="size-4" />,
|
||||
},
|
||||
];
|
||||
@@ -157,12 +156,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className={triggerClass}>
|
||||
Análise
|
||||
Relatórios
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<DropdownLinkList items={analiseItems} />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className={triggerClass}>
|
||||
Ferramentas
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<FerramentasDropdownContent />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</nav>
|
||||
@@ -198,22 +206,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
icon={<RiArrowLeftRightLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Lançamentos
|
||||
lançamentos
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/pre-lancamentos"
|
||||
icon={<RiInboxLine className="size-4" />}
|
||||
onClick={close}
|
||||
badge={preLancamentosCount}
|
||||
>
|
||||
Pré-Lançamentos
|
||||
pré-lançamentos
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/calendario"
|
||||
icon={<RiCalendarEventLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Calendário
|
||||
calendário
|
||||
</MobileNavLink>
|
||||
|
||||
<MobileSectionLabel label="Finanças" />
|
||||
@@ -222,21 +229,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
icon={<RiBankCard2Line className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Cartões
|
||||
cartões
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/contas"
|
||||
icon={<RiBankLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Contas
|
||||
contas
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/orcamentos"
|
||||
icon={<RiFundsLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Orçamentos
|
||||
orçamentos
|
||||
</MobileNavLink>
|
||||
|
||||
<MobileSectionLabel label="Organização" />
|
||||
@@ -245,21 +252,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
icon={<RiGroupLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Pagadores
|
||||
pagadores
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/categorias"
|
||||
icon={<RiPriceTag3Line className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Categorias
|
||||
categorias
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/anotacoes"
|
||||
icon={<RiTodoLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Anotações
|
||||
anotações
|
||||
</MobileNavLink>
|
||||
|
||||
<MobileSectionLabel label="Análise" />
|
||||
@@ -268,22 +275,25 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
|
||||
icon={<RiSparklingLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Insights
|
||||
insights
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/relatorios/tendencias"
|
||||
icon={<RiFileChartLine className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Tendências
|
||||
tendências
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
href="/relatorios/uso-cartoes"
|
||||
icon={<RiBankCard2Line className="size-4" />}
|
||||
onClick={close}
|
||||
>
|
||||
Uso de Cartões
|
||||
uso de cartões
|
||||
</MobileNavLink>
|
||||
|
||||
<MobileSectionLabel label="Ferramentas" />
|
||||
<MobileFerramentasItems onClose={close} />
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { RiSettings2Line } from "@remixicon/react";
|
||||
import {
|
||||
RiLogoutCircleLine,
|
||||
RiMessageLine,
|
||||
RiSettings2Line,
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import LogoutButton from "@/components/auth/logout-button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FeedbackDialogBody } from "@/components/feedback/feedback-dialog";
|
||||
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -12,7 +18,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { version } from "@/package.json";
|
||||
|
||||
const itemClass =
|
||||
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent";
|
||||
|
||||
type TopbarUserProps = {
|
||||
user: {
|
||||
@@ -25,58 +38,109 @@ type TopbarUserProps = {
|
||||
};
|
||||
|
||||
export function TopbarUser({ user, pagadorAvatarUrl }: TopbarUserProps) {
|
||||
const router = useRouter();
|
||||
const [logoutLoading, setLogoutLoading] = useState(false);
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
|
||||
const avatarSrc = useMemo(() => {
|
||||
if (pagadorAvatarUrl) return getAvatarSrc(pagadorAvatarUrl);
|
||||
if (user.image) return user.image;
|
||||
return getAvatarSrc(null);
|
||||
}, [user.image, pagadorAvatarUrl]);
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => router.push("/login"),
|
||||
onRequest: () => setLogoutLoading(true),
|
||||
onResponse: () => setLogoutLoading(false),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-full ring-2 ring-foreground/30 hover:ring-foreground/60 transition-all focus-visible:outline-none focus-visible:ring-foreground"
|
||||
>
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={user.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 rounded-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60 p-2" sideOffset={10}>
|
||||
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={user.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="size-9 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
<Dialog open={feedbackOpen} onOpenChange={setFeedbackOpen}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full border-background bg-background shadow-lg"
|
||||
aria-label="Menu do usuário"
|
||||
>
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={`Avatar de ${user.name}`}
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 rounded-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60 p-2" sideOffset={10}>
|
||||
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={user.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="size-9 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
<Link href="/ajustes" className={cn(itemClass, "text-foreground")}>
|
||||
<RiSettings2Line className="size-4 text-muted-foreground shrink-0" />
|
||||
Ajustes
|
||||
</Link>
|
||||
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(itemClass, "text-foreground")}
|
||||
>
|
||||
<RiMessageLine className="size-4 text-muted-foreground shrink-0" />
|
||||
Enviar Feedback
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={logoutLoading}
|
||||
aria-busy={logoutLoading}
|
||||
className={cn(
|
||||
itemClass,
|
||||
"text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{logoutLoading ? (
|
||||
<Spinner className="size-4 shrink-0" />
|
||||
) : (
|
||||
<RiLogoutCircleLine className="size-4 shrink-0" />
|
||||
)}
|
||||
{logoutLoading ? "Saindo..." : "Sair"}
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-mono text-muted-foreground/40 select-none">
|
||||
Versão {version}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
<Link
|
||||
href="/ajustes"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<RiSettings2Line className="size-4 text-muted-foreground" />
|
||||
Ajustes
|
||||
</Link>
|
||||
<div className="px-1 py-0.5">
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FeedbackDialogBody onClose={() => setFeedbackOpen(false)} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RiArrowDownBoxFill } from "@remixicon/react";
|
||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
@@ -73,8 +73,8 @@ function NavigationMenuTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<RiArrowDownBoxFill
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
<RiArrowDropDownLine
|
||||
className="relative top-px ml-1 size-4 text-primary transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
|
||||
Reference in New Issue
Block a user