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

@@ -5,6 +5,27 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [1.7.0] - 2026-02-24
### Adicionado
- **Topbar de navegação** como experimento: substitui o header fixo por uma topbar com backdrop blur (`bg-card/80`), agrupando os links de navegação em 5 grupos lógicos (Dashboard, Lançamentos, Cartões, Relatórios, Ferramentas)
- **Dropdown "Ferramentas"** na topbar: consolida calculadora e modo privacidade em um único menu (`components/topbar/ferramentas-dropdown.tsx`)
- **NotificationBell expandida**: passa a exibir notificações de orçamentos estourados e pré-lançamentos pendentes além das notificações gerais, com seções separadas por tipo e contagem agregada
### Alterado
- **Logo** refatorado com variante `compact` usada na topbar
- **TopbarUser**: incorpora o botão de logout (antes em `logout-button.tsx` separado)
- **Topbar**: links em lowercase; layout centralizado em `max-w-8xl`; `radix-ui ^1.4.3` adicionado como dependência
- **Gráfico de relatório de categorias** (`category-report-chart`): refatoração interna
### Removido
- `components/header-dashboard.tsx` — substituído pela topbar
- `components/auth/logout-button.tsx` — lógica incorporada ao `TopbarUser`
- `components/privacy-mode-toggle.tsx` — movido para o `FerramentasDropdown`
## [1.6.3] - 2026-02-19 ## [1.6.3] - 2026-02-19
### Corrigido ### Corrigido

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiSettings2Line />} icon={<RiSettings2Line />}
title="Ajustes" title="Ajustes"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiTodoLine />} icon={<RiTodoLine />}
title="Notas" title="Notas"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiCalendarEventLine />} icon={<RiCalendarEventLine />}
title="Calendário" title="Calendário"

View File

@@ -22,7 +22,7 @@ export default function FaturaLoading() {
{/* Seção de lançamentos */} {/* Seção de lançamentos */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="space-y-6"> <div className="space-y-6 pt-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiBankCard2Line />} icon={<RiBankCard2Line />}
title="Cartões" title="Cartões"

View File

@@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function CartoesLoading() { export default function CartoesLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<div className="space-y-6"> <div className="space-y-6 pt-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" /> <Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiPriceTag3Line />} icon={<RiPriceTag3Line />}
title="Categorias" title="Categorias"

View File

@@ -20,7 +20,7 @@ export default function ExtratoLoading() {
{/* Seção de lançamentos */} {/* Seção de lançamentos */}
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="space-y-6"> <div className="space-y-6 pt-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiBankLine />} icon={<RiBankLine />}
title="Contas" title="Contas"

View File

@@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function ContasLoading() { export default function ContasLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<div className="space-y-6"> <div className="space-y-6 pt-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" /> <Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiSecurePaymentLine />} icon={<RiSecurePaymentLine />}
title="Análise de Parcelas" title="Análise de Parcelas"

View File

@@ -36,7 +36,7 @@ export default async function Page({ searchParams }: PageProps) {
const { disableMagnetlines, dashboardWidgets } = preferences; const { disableMagnetlines, dashboardWidgets } = preferences;
return ( return (
<main className="flex flex-col gap-4 px-6"> <main className="flex flex-col gap-4">
<DashboardWelcome <DashboardWelcome
name={user.name} name={user.name}
disableMagnetlines={disableMagnetlines} disableMagnetlines={disableMagnetlines}

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiSparklingLine />} icon={<RiSparklingLine />}
title="Insights" title="Insights"

View File

@@ -6,7 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function InsightsLoading() { export default function InsightsLoading() {
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<div className="space-y-6"> <div className="space-y-6 pt-4">
{/* Header */} {/* Header */}
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-10 w-64 rounded-2xl bg-foreground/10" /> <Skeleton className="h-10 w-64 rounded-2xl bg-foreground/10" />

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiArrowLeftRightLine />} icon={<RiArrowLeftRightLine />}
title="Lançamentos" title="Lançamentos"

View File

@@ -14,7 +14,7 @@ export default function LancamentosLoading() {
{/* Month Picker placeholder */} {/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
<div className="space-y-6"> <div className="space-y-6 pt-4">
{/* Header com título e botão */} {/* Header com título e botão */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" /> <Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />

View File

@@ -61,7 +61,7 @@ export default async function DashboardLayout({
/> />
<div className="flex flex-1 flex-col pt-14"> <div className="flex flex-1 flex-col pt-14">
<div className="@container/main flex flex-1 flex-col gap-2"> <div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 w-full max-w-8xl mx-auto px-4"> <div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4">
{children} {children}
</div> </div>
</div> </div>

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiFundsLine />} icon={<RiFundsLine />}
title="Orçamentos" title="Orçamentos"

View File

@@ -10,7 +10,7 @@ export default function OrcamentosLoading() {
{/* Month Picker placeholder */} {/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" /> <div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
<div className="space-y-6"> <div className="space-y-6 pt-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -42,7 +42,7 @@ export default function PagadorDetailsLoading() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="space-y-6"> <div className="space-y-6 pt-4">
<div className="flex gap-2 border-b"> <div className="flex gap-2 border-b">
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" /> <Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" /> <Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiGroupLine />} icon={<RiGroupLine />}
title="Pagadores" title="Pagadores"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiInboxLine />} icon={<RiInboxLine />}
title="Pré-Lançamentos" title="Pré-Lançamentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiFileChartLine />} icon={<RiFileChartLine />}
title="Tendências" title="Tendências"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiBankCard2Line />} icon={<RiBankCard2Line />}
title="Uso de Cartões" title="Uso de Cartões"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-6 px-6"> <section className="space-y-6 pt-4">
<PageDescription <PageDescription
icon={<RiStore2Line />} icon={<RiStore2Line />}
title="Top Estabelecimentos" title="Top Estabelecimentos"

View File

@@ -155,40 +155,38 @@ export default async function Page() {
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
{/* Navigation */} {/* Navigation */}
<header className="sticky top-0 z-50 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"> <header className="sticky top-0 z-50 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="container flex h-16 items-center justify-between"> <div className="max-w-8xl mx-auto px-4 flex h-14 items-center justify-between">
<div className="flex items-center"> <Logo variant="compact" />
<Logo />
</div>
{/* Center Navigation Links */} {/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-6 absolute left-1/2 transform -translate-x-1/2"> <nav className="hidden md:flex items-center gap-6 absolute left-1/2 transform -translate-x-1/2">
<a <a
href="#telas" href="#telas"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
Conheça as telas Conheça as telas
</a> </a>
<a <a
href="#funcionalidades" href="#funcionalidades"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
Funcionalidades Funcionalidades
</a> </a>
<a <a
href="#companion" href="#companion"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
Companion Companion
</a> </a>
<a <a
href="#stack" href="#stack"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
Stack Stack
</a> </a>
<a <a
href="#como-usar" href="#como-usar"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
Como usar Como usar
</a> </a>
@@ -231,7 +229,7 @@ export default async function Page() {
{/* Background gradient */} {/* Background gradient */}
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-primary/5 via-transparent to-transparent" /> <div className="pointer-events-none absolute inset-0 bg-linear-to-b from-primary/5 via-transparent to-transparent" />
<div className="container relative"> <div className="max-w-8xl mx-auto px-4 relative">
<div className="mx-auto flex max-w-5xl flex-col items-center text-center gap-5 md:gap-6"> <div className="mx-auto flex max-w-5xl flex-col items-center text-center gap-5 md:gap-6">
<Logo variant="small" className="h-12 w-12 mb-1" /> <Logo variant="small" className="h-12 w-12 mb-1" />
@@ -304,7 +302,7 @@ export default async function Page() {
{/* Metrics Bar */} {/* Metrics Bar */}
<section className="py-8 md:py-12 border-y"> <section className="py-8 md:py-12 border-y">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 md:gap-8"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4 md:gap-8">
<div className="flex flex-col items-center text-center gap-1.5"> <div className="flex flex-col items-center text-center gap-1.5">
@@ -350,7 +348,7 @@ export default async function Page() {
{/* Dashboard Preview Section */} {/* Dashboard Preview Section */}
<section className="py-6 md:py-16"> <section className="py-6 md:py-16">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<AnimateOnScroll> <AnimateOnScroll>
<div> <div>
@@ -378,7 +376,7 @@ export default async function Page() {
{/* Screenshots Gallery Section */} {/* Screenshots Gallery Section */}
<section id="telas" className="py-12 md:py-24"> <section id="telas" className="py-12 md:py-24">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<AnimateOnScroll> <AnimateOnScroll>
<div className="text-center mb-8 md:mb-12"> <div className="text-center mb-8 md:mb-12">
@@ -430,7 +428,7 @@ export default async function Page() {
{/* Features Section */} {/* Features Section */}
<section id="funcionalidades" className="py-12 md:py-24"> <section id="funcionalidades" className="py-12 md:py-24">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<AnimateOnScroll> <AnimateOnScroll>
<div className="text-center mb-8 md:mb-12"> <div className="text-center mb-8 md:mb-12">
@@ -512,7 +510,7 @@ export default async function Page() {
{/* Companion Section */} {/* Companion Section */}
<section id="companion" className="py-12 md:py-24"> <section id="companion" className="py-12 md:py-24">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<AnimateOnScroll> <AnimateOnScroll>
<div className="grid gap-8 md:gap-12 md:grid-cols-2 items-center"> <div className="grid gap-8 md:gap-12 md:grid-cols-2 items-center">
@@ -625,7 +623,7 @@ export default async function Page() {
{/* Tech Stack Section */} {/* Tech Stack Section */}
<section id="stack" className="py-12 md:py-24"> <section id="stack" className="py-12 md:py-24">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<AnimateOnScroll> <AnimateOnScroll>
<div className="text-center mb-8 md:mb-12"> <div className="text-center mb-8 md:mb-12">
@@ -748,7 +746,7 @@ export default async function Page() {
{/* How to run Section */} {/* How to run Section */}
<section id="como-usar" className="py-12 md:py-24"> <section id="como-usar" className="py-12 md:py-24">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
<AnimateOnScroll> <AnimateOnScroll>
<div className="text-center mb-8 md:mb-12"> <div className="text-center mb-8 md:mb-12">
@@ -783,7 +781,7 @@ export default async function Page() {
{/* Who is this for Section */} {/* Who is this for Section */}
<section className="py-12 md:py-24"> <section className="py-12 md:py-24">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
<AnimateOnScroll> <AnimateOnScroll>
<div className="text-center mb-8 md:mb-12"> <div className="text-center mb-8 md:mb-12">
@@ -882,7 +880,7 @@ export default async function Page() {
{/* CTA Section */} {/* CTA Section */}
<section className="py-12 md:py-24"> <section className="py-12 md:py-24">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<AnimateOnScroll> <AnimateOnScroll>
<div className="mx-auto max-w-3xl text-center px-4 sm:px-0"> <div className="mx-auto max-w-3xl text-center px-4 sm:px-0">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4"> <h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
@@ -924,11 +922,11 @@ export default async function Page() {
{/* Footer */} {/* Footer */}
<footer className="border-t py-8 md:py-12 mt-auto"> <footer className="border-t py-8 md:py-12 mt-auto">
<div className="container"> <div className="max-w-8xl mx-auto px-4">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<div className="grid gap-8 sm:grid-cols-2 md:grid-cols-3"> <div className="grid gap-8 sm:grid-cols-2 md:grid-cols-3">
<div className="sm:col-span-2 md:col-span-1"> <div className="sm:col-span-2 md:col-span-1">
<Logo /> <Logo variant="compact" />
<p className="text-sm text-muted-foreground mt-3 md:mt-4"> <p className="text-sm text-muted-foreground mt-3 md:mt-4">
Projeto pessoal de gestão financeira. Open source e Projeto pessoal de gestão financeira. Open source e
self-hosted. self-hosted.

View File

@@ -25,7 +25,7 @@ export default function RootLayout({
<head> <head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" /> <meta name="apple-mobile-web-app-title" content="OpenMonetis" />
</head> </head>
<body className="antialiased" suppressHydrationWarning> <body className="subpixel-antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light"> <ThemeProvider attribute="class" defaultTheme="light">
{children} {children}
<Toaster position="top-right" /> <Toaster position="top-right" />

View File

@@ -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>
);
}

View File

@@ -31,7 +31,7 @@ type CalculatorDialogButtonProps = {
onSelectValue?: (value: string) => void; onSelectValue?: (value: string) => void;
}; };
function CalculatorDialogContent({ export function CalculatorDialogContent({
open, open,
onSelectValue, onSelectValue,
}: { }: {

View File

@@ -158,7 +158,7 @@ export function CardItem({
); );
return ( 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"> <CardHeader className="space-y-2 px-0 pb-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
@@ -209,14 +209,14 @@ export function CardItem({
</div> </div>
{brandAsset ? ( {brandAsset ? (
<div className="flex items-center justify-center rounded-lg py-1"> <div className="flex items-center justify-center py-1">
<Image <Image
src={brandAsset} src={brandAsset}
alt={`Bandeira ${brand}`} alt={`Bandeira ${brand}`}
width={42} width={36}
height={42} height={36}
className={cn( className={cn(
"h-6 w-auto rounded-full", "h-5 w-auto rounded",
isInactive && "grayscale opacity-40", isInactive && "grayscale opacity-40",
)} )}
/> />

View File

@@ -125,7 +125,7 @@ export function CardsPage({
} }
return ( 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) => ( {list.map((card) => (
<CardItem <CardItem
key={card.id} key={card.id}

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { main_font } from "@/public/fonts/font_index";
import MagnetLines from "../magnet-lines"; import MagnetLines from "../magnet-lines";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
@@ -54,9 +53,7 @@ export function DashboardWelcome({
const greeting = getGreeting(); const greeting = getGreeting();
return ( return (
<Card <Card className="relative px-6 py-12 bg-welcome-banner overflow-hidden">
className={`${main_font.className} relative px-6 py-12 bg-welcome-banner border-none shadow-none overflow-hidden`}
>
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<MagnetLines <MagnetLines
rows={8} rows={8}

View File

@@ -270,7 +270,7 @@ export function InvoiceSummaryCard({
alt={`Bandeira ${cardBrand}`} alt={`Bandeira ${cardBrand}`}
width={32} width={32}
height={32} height={32}
className="h-5 w-auto rounded-full" className="h-5 w-auto rounded"
/> />
<span className="truncate">{cardBrand}</span> <span className="truncate">{cardBrand}</span>
</div> </div>

View File

@@ -64,14 +64,74 @@ const feedbackCategories = [
}, },
]; ];
export function FeedbackDialog() { export function FeedbackDialogBody({ onClose }: { onClose?: () => void }) {
const [open, setOpen] = useState(false);
const handleCategoryClick = (url: string) => { const handleCategoryClick = (url: string) => {
window.open(url, "_blank", "noopener,noreferrer"); 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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<Tooltip> <Tooltip>
@@ -93,62 +153,7 @@ export function FeedbackDialog() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Enviar Feedback</TooltipContent> <TooltipContent>Enviar Feedback</TooltipContent>
</Tooltip> </Tooltip>
<FeedbackDialogBody onClose={() => setOpen(false)} />
<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>
</Dialog> </Dialog>
); );
} }

View File

@@ -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>
);
}

View File

@@ -46,7 +46,7 @@ export function PaymentMethodSection({
return ( return (
<> <>
{!isUpdateMode ? ( {!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 <div
className={cn( className={cn(
"space-y-1 w-full", "space-y-1 w-full",

View File

@@ -41,9 +41,9 @@ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-72"> <SheetContent side="right" className="w-72">
<SheetHeader> <SheetHeader className="border-b pb-4">
<SheetTitle> <SheetTitle asChild>
<Logo /> <Logo variant="compact" />
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>

View File

@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils/ui";
import { version } from "@/package.json"; import { version } from "@/package.json";
interface LogoProps { interface LogoProps {
variant?: "full" | "small"; variant?: "full" | "small" | "compact";
className?: string; className?: string;
showVersion?: boolean; showVersion?: boolean;
} }
@@ -13,6 +13,29 @@ export function Logo({
className, className,
showVersion = false, showVersion = false,
}: LogoProps) { }: 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") { if (variant === "small") {
return ( return (
<Image <Image
@@ -45,8 +68,8 @@ export function Logo({
priority priority
/> />
{showVersion && ( {showVersion && (
<span className="text-[10px] font-medium text-muted-foreground"> <span className="text-[9px] font-medium text-muted-foreground">
v{version} {version}
</span> </span>
)} )}
</div> </div>

View File

@@ -79,7 +79,7 @@ export default function MonthNavigation() {
}; };
return ( 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"> <div className="flex items-center gap-1">
<NavigationButton <NavigationButton
direction="left" direction="left"

View File

@@ -2,17 +2,23 @@
import { import {
RiAlertFill, RiAlertFill,
RiArrowRightLine,
RiBankCardLine,
RiBarChart2Line,
RiCheckboxCircleFill, RiCheckboxCircleFill,
RiErrorWarningLine,
RiFileListLine,
RiInboxLine,
RiNotification3Line, RiNotification3Line,
RiTimeLine, RiTimeLine,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
@@ -27,26 +33,26 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } 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"; import { cn } from "@/lib/utils/ui";
type NotificationBellProps = { type NotificationBellProps = {
notifications: DashboardNotification[]; notifications: DashboardNotification[];
totalCount: number; totalCount: number;
budgetNotifications: BudgetNotification[];
preLancamentosCount?: number;
}; };
function formatDate(dateString: string): string { 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); 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)); const date = new Date(Date.UTC(year, month - 1, day));
return date.toLocaleDateString("pt-BR", { return date.toLocaleDateString("pt-BR", {
day: "2-digit", day: "2-digit",
month: "short", 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); }).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({ export function NotificationBell({
notifications, notifications,
totalCount, totalCount,
budgetNotifications,
preLancamentosCount = 0,
}: NotificationBellProps) { }: NotificationBellProps) {
const [open, setOpen] = useState(false); 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 ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
@@ -74,7 +108,6 @@ export function NotificationBell({
type="button" type="button"
aria-label="Notificações" aria-label="Notificações"
aria-expanded={open} aria-expanded={open}
data-has-notifications={hasNotifications}
className={cn( className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }), buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200", "group relative text-muted-foreground transition-all duration-200",
@@ -103,23 +136,27 @@ export function NotificationBell({
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}> <TooltipContent side="bottom" sideOffset={8}>
Pagamentos para os próximos 5 dias. Notificações
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
sideOffset={12} 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"> {/* Header */}
<span>Notificações | Próximos 5 dias.</span> <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 && ( {hasNotifications && (
<Badge variant="outline" className="text-[10px] font-semibold"> <Badge variant="outline" className="text-[10px] font-semibold">
{totalCount} {totalCount === 1 ? "item" : "itens"} {effectiveTotalCount}{" "}
{effectiveTotalCount === 1 ? "item" : "itens"}
</Badge> </Badge>
)} )}
</DropdownMenuLabel> </DropdownMenuLabel>
{notifications.length === 0 ? (
{!hasNotifications ? (
<div className="px-4 py-8"> <div className="px-4 py-8">
<Empty> <Empty>
<EmptyMedia> <EmptyMedia>
@@ -132,71 +169,169 @@ export function NotificationBell({
</Empty> </Empty>
</div> </div>
) : ( ) : (
<div className="max-h-[400px] overflow-y-auto py-2"> <div className="max-h-[460px] overflow-y-auto pb-2">
{notifications.map((notification) => ( {/* Pré-lançamentos */}
<DropdownMenuItem {preLancamentosCount > 0 && (
key={notification.id} <div>
className={cn( <SectionLabel
"group relative flex flex-col gap-2 rounded-none border-b border-dashed last:border-0 p-2.5", icon={<RiInboxLine className="size-3" />}
"cursor-default focus:bg-transparent data-highlighted:bg-accent/60", title="Pré-lançamentos"
)} />
> <Link
<div className="flex items-start justify-between w-full gap-2"> href="/pre-lancamentos"
<div className="flex items-start gap-2 flex-1 min-w-0"> onClick={() => setOpen(false)}
<div className="group mx-1 mb-1 flex items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-accent/60"
className={cn( >
"flex items-center justify-center text-sm transition-all duration-200", <p className="flex-1 text-xs leading-snug text-foreground">
)} {preLancamentosCount === 1
> ? "1 pré-lançamento aguardando revisão"
{notification.status === "overdue" ? ( : `${preLancamentosCount} pré-lançamentos aguardando revisão`}
<RiAlertFill color="red" className="size-4" /> </p>
) : ( <RiArrowRightLine className="size-3 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5" />
<RiTimeLine className="size-4" /> </Link>
)} </div>
</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>
{notification.showAmount && notification.amount > 0 && ( {/* Orçamentos */}
<span className="font-medium"> {budgetNotifications.length > 0 && (
{formatCurrency(notification.amount)} <div>
</span> <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>
</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> </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> </div>
)} )}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -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>
);
};

View File

@@ -45,7 +45,10 @@ export function RefreshPageButton({
{...props} {...props}
> >
<RiRefreshLine <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 aria-hidden
/> />
</button> </button>

View File

@@ -1,34 +1,135 @@
"use client"; "use client";
import { RiPieChartLine } from "@remixicon/react"; import { RiPieChartLine } from "@remixicon/react";
import { useMemo } from "react"; import * as React from "react";
import { import {
Area, Area,
AreaChart, AreaChart,
CartesianGrid, CartesianGrid,
ResponsiveContainer, type TooltipProps,
Tooltip,
XAxis, XAxis,
YAxis,
} from "recharts"; } from "recharts";
import { EmptyState } from "@/components/empty-state"; 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 { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import { CATEGORY_COLORS } from "@/lib/utils/category-colors"; 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 { interface CategoryReportChartProps {
data: CategoryChartData; 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) { export function CategoryReportChart({ data }: CategoryReportChartProps) {
const { chartData, categories } = data; 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) { if (categories.length === 0 || chartData.length === 0) {
return ( return (
<EmptyState <EmptyState
@@ -40,165 +141,91 @@ export function CategoryReportChart({ data }: CategoryReportChartProps) {
); );
} }
// Get top 10 categories by total spending const firstMonth = chartData[0]?.month ?? "";
const { topCategories, filteredChartData } = useMemo(() => { const lastMonth = chartData[chartData.length - 1]?.month ?? "";
// Calculate total for each category across all periods const periodLabel =
const categoriesWithTotal = categories.map((category) => { firstMonth === lastMonth ? firstMonth : `${firstMonth} ${lastMonth}`;
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]);
return ( return (
<Card> <Card className="pt-0">
<CardHeader> <CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<CardTitle> <div className="grid flex-1 gap-1">
Evolução por Categoria - Top {topCategories.length} <CardTitle>Evolução por Categoria</CardTitle>
</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> </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 ( <CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<div className="rounded-lg border bg-background p-3 shadow-md"> <ChartContainer
<div className="mb-2 font-semibold"> config={chartConfig}
{payload[0]?.payload?.month} className="aspect-auto h-[300px] w-full"
</div> >
<div className="space-y-1"> <AreaChart data={filteredChartData}>
{payload.map((entry, index) => { <defs>
if (entry.dataKey === "month") return null; {topCategories.map((cat, index) => {
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
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];
return ( return (
<Area <linearGradient
key={category.id} key={cat.id}
type="monotone" id={`fill-${cat.id}`}
dataKey={category.name} x1="0"
stroke={color} y1="0"
strokeWidth={2} x2="0"
fill={`url(#gradient-${category.id})`} y2="1"
fillOpacity={1} >
/> <stop offset="5%" stopColor={color} stopOpacity={0.8} />
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
</linearGradient>
); );
})} })}
</AreaChart> </defs>
</ResponsiveContainer>
</div>
{/* Legend */} <CartesianGrid vertical={false} />
<div className="mt-4 flex flex-wrap gap-4">
{topCategories.map((category, index) => { <XAxis
const color = CHART_COLORS[index % CHART_COLORS.length]; dataKey="month"
return ( tickLine={false}
<div key={category.id} className="flex items-center gap-2"> axisLine={false}
<div tickMargin={8}
className="h-3 w-3 rounded-full" minTickGap={32}
style={{ backgroundColor: color }} />
/>
<span className="text-sm text-muted-foreground"> <ChartTooltip cursor={false} content={<AreaTooltip />} />
{category.name}
</span> {topCategories.map((cat, index) => (
</div> <Area
); key={cat.id}
})} dataKey={cat.name}
</div> 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> </CardContent>
</Card> </Card>
); );

View File

@@ -1,12 +1,9 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler"; 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 { NotificationBell } from "@/components/notificacoes/notification-bell";
import { PrivacyModeToggle } from "@/components/privacy-mode-toggle";
import { RefreshPageButton } from "@/components/refresh-page-button"; import { RefreshPageButton } from "@/components/refresh-page-button";
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications"; import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
import { Logo } from "../logo";
import { TopNavMenu } from "./top-nav-menu"; import { TopNavMenu } from "./top-nav-menu";
import { TopbarUser } from "./topbar-user"; import { TopbarUser } from "./topbar-user";
@@ -29,49 +26,26 @@ export function AppTopbar({
notificationsSnapshot, notificationsSnapshot,
}: AppTopbarProps) { }: AppTopbarProps) {
return ( 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"> <div className="w-full max-w-8xl mx-auto px-4 flex items-center gap-4 h-full">
{/* Logo */} {/* Logo */}
<Link <Link href="/dashboard" className="shrink-0 mr-1">
href="/dashboard" <Logo variant="compact" />
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> </Link>
{/* Navigation */} {/* Navigation */}
<TopNavMenu preLancamentosCount={preLancamentosCount} /> <TopNavMenu />
{/* Right-side actions */} {/* Right-side actions */}
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-2">
<NotificationBell <NotificationBell
notifications={notificationsSnapshot.notifications} notifications={notificationsSnapshot.notifications}
totalCount={notificationsSnapshot.totalCount} totalCount={notificationsSnapshot.totalCount}
budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount}
/> />
<CalculatorDialogButton withTooltip />
<RefreshPageButton /> <RefreshPageButton />
<PrivacyModeToggle />
<AnimatedThemeToggler /> <AnimatedThemeToggler />
<span
aria-hidden
className="h-5 w-px bg-foreground/20 mx-1 hidden sm:block"
/>
<FeedbackDialog />
</div> </div>
{/* User avatar */} {/* User avatar */}

View 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>
);
}

View File

@@ -33,33 +33,32 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import type { DropdownLinkItem } from "./dropdown-link-list"; import type { DropdownLinkItem } from "./dropdown-link-list";
import { DropdownLinkList } from "./dropdown-link-list"; import { DropdownLinkList } from "./dropdown-link-list";
import {
FerramentasDropdownContent,
MobileFerramentasItems,
} from "./ferramentas-dropdown";
import { MobileNavLink, MobileSectionLabel } from "./mobile-nav-link"; import { MobileNavLink, MobileSectionLabel } from "./mobile-nav-link";
import { triggerClass } from "./nav-styles"; import { triggerClass } from "./nav-styles";
import { SimpleNavLink } from "./simple-nav-link"; import { SimpleNavLink } from "./simple-nav-link";
type TopNavMenuProps = { export function TopNavMenu() {
preLancamentosCount?: number;
};
export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const close = () => setSheetOpen(false); const close = () => setSheetOpen(false);
const lancamentosItems: DropdownLinkItem[] = [ const lancamentosItems: DropdownLinkItem[] = [
{ {
href: "/lancamentos", href: "/lancamentos",
label: "Lançamentos", label: "lançamentos",
icon: <RiArrowLeftRightLine className="size-4" />, icon: <RiArrowLeftRightLine className="size-4" />,
}, },
{ {
href: "/pre-lancamentos", href: "/pre-lancamentos",
label: "Pré-Lançamentos", label: "pré-lançamentos",
icon: <RiInboxLine className="size-4" />, icon: <RiInboxLine className="size-4" />,
badge: preLancamentosCount,
}, },
{ {
href: "/calendario", href: "/calendario",
label: "Calendário", label: "calendário",
icon: <RiCalendarEventLine className="size-4" />, icon: <RiCalendarEventLine className="size-4" />,
}, },
]; ];
@@ -67,17 +66,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
const financasItems: DropdownLinkItem[] = [ const financasItems: DropdownLinkItem[] = [
{ {
href: "/cartoes", href: "/cartoes",
label: "Cartões", label: "cartões",
icon: <RiBankCard2Line className="size-4" />, icon: <RiBankCard2Line className="size-4" />,
}, },
{ {
href: "/contas", href: "/contas",
label: "Contas", label: "contas",
icon: <RiBankLine className="size-4" />, icon: <RiBankLine className="size-4" />,
}, },
{ {
href: "/orcamentos", href: "/orcamentos",
label: "Orçamentos", label: "orçamentos",
icon: <RiFundsLine className="size-4" />, icon: <RiFundsLine className="size-4" />,
}, },
]; ];
@@ -85,17 +84,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
const organizacaoItems: DropdownLinkItem[] = [ const organizacaoItems: DropdownLinkItem[] = [
{ {
href: "/pagadores", href: "/pagadores",
label: "Pagadores", label: "pagadores",
icon: <RiGroupLine className="size-4" />, icon: <RiGroupLine className="size-4" />,
}, },
{ {
href: "/categorias", href: "/categorias",
label: "Categorias", label: "categorias",
icon: <RiPriceTag3Line className="size-4" />, icon: <RiPriceTag3Line className="size-4" />,
}, },
{ {
href: "/anotacoes", href: "/anotacoes",
label: "Anotações", label: "anotações",
icon: <RiTodoLine className="size-4" />, icon: <RiTodoLine className="size-4" />,
}, },
]; ];
@@ -103,17 +102,17 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
const analiseItems: DropdownLinkItem[] = [ const analiseItems: DropdownLinkItem[] = [
{ {
href: "/insights", href: "/insights",
label: "Insights", label: "insights",
icon: <RiSparklingLine className="size-4" />, icon: <RiSparklingLine className="size-4" />,
}, },
{ {
href: "/relatorios/tendencias", href: "/relatorios/tendencias",
label: "Tendências", label: "tendências",
icon: <RiFileChartLine className="size-4" />, icon: <RiFileChartLine className="size-4" />,
}, },
{ {
href: "/relatorios/uso-cartoes", href: "/relatorios/uso-cartoes",
label: "Uso de Cartões", label: "uso de cartões",
icon: <RiBankCard2Line className="size-4" />, icon: <RiBankCard2Line className="size-4" />,
}, },
]; ];
@@ -157,12 +156,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuTrigger className={triggerClass}> <NavigationMenuTrigger className={triggerClass}>
Análise Relatórios
</NavigationMenuTrigger> </NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>
<DropdownLinkList items={analiseItems} /> <DropdownLinkList items={analiseItems} />
</NavigationMenuContent> </NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className={triggerClass}>
Ferramentas
</NavigationMenuTrigger>
<NavigationMenuContent>
<FerramentasDropdownContent />
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
</nav> </nav>
@@ -198,22 +206,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiArrowLeftRightLine className="size-4" />} icon={<RiArrowLeftRightLine className="size-4" />}
onClick={close} onClick={close}
> >
Lançamentos lançamentos
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/pre-lancamentos" href="/pre-lancamentos"
icon={<RiInboxLine className="size-4" />} icon={<RiInboxLine className="size-4" />}
onClick={close} onClick={close}
badge={preLancamentosCount}
> >
P-Lançamentos p-lançamentos
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/calendario" href="/calendario"
icon={<RiCalendarEventLine className="size-4" />} icon={<RiCalendarEventLine className="size-4" />}
onClick={close} onClick={close}
> >
Calendário calendário
</MobileNavLink> </MobileNavLink>
<MobileSectionLabel label="Finanças" /> <MobileSectionLabel label="Finanças" />
@@ -222,21 +229,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiBankCard2Line className="size-4" />} icon={<RiBankCard2Line className="size-4" />}
onClick={close} onClick={close}
> >
Cartões cartões
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/contas" href="/contas"
icon={<RiBankLine className="size-4" />} icon={<RiBankLine className="size-4" />}
onClick={close} onClick={close}
> >
Contas contas
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/orcamentos" href="/orcamentos"
icon={<RiFundsLine className="size-4" />} icon={<RiFundsLine className="size-4" />}
onClick={close} onClick={close}
> >
Orçamentos orçamentos
</MobileNavLink> </MobileNavLink>
<MobileSectionLabel label="Organização" /> <MobileSectionLabel label="Organização" />
@@ -245,21 +252,21 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiGroupLine className="size-4" />} icon={<RiGroupLine className="size-4" />}
onClick={close} onClick={close}
> >
Pagadores pagadores
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/categorias" href="/categorias"
icon={<RiPriceTag3Line className="size-4" />} icon={<RiPriceTag3Line className="size-4" />}
onClick={close} onClick={close}
> >
Categorias categorias
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/anotacoes" href="/anotacoes"
icon={<RiTodoLine className="size-4" />} icon={<RiTodoLine className="size-4" />}
onClick={close} onClick={close}
> >
Anotações anotações
</MobileNavLink> </MobileNavLink>
<MobileSectionLabel label="Análise" /> <MobileSectionLabel label="Análise" />
@@ -268,22 +275,25 @@ export function TopNavMenu({ preLancamentosCount = 0 }: TopNavMenuProps) {
icon={<RiSparklingLine className="size-4" />} icon={<RiSparklingLine className="size-4" />}
onClick={close} onClick={close}
> >
Insights insights
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/relatorios/tendencias" href="/relatorios/tendencias"
icon={<RiFileChartLine className="size-4" />} icon={<RiFileChartLine className="size-4" />}
onClick={close} onClick={close}
> >
Tendências tendências
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/relatorios/uso-cartoes" href="/relatorios/uso-cartoes"
icon={<RiBankCard2Line className="size-4" />} icon={<RiBankCard2Line className="size-4" />}
onClick={close} onClick={close}
> >
Uso de Cartões uso de cartões
</MobileNavLink> </MobileNavLink>
<MobileSectionLabel label="Ferramentas" />
<MobileFerramentasItems onClose={close} />
</nav> </nav>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View File

@@ -1,10 +1,16 @@
"use client"; "use client";
import { RiSettings2Line } from "@remixicon/react"; import {
RiLogoutCircleLine,
RiMessageLine,
RiSettings2Line,
} from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react"; import { useRouter } from "next/navigation";
import LogoutButton from "@/components/auth/logout-button"; import { useMemo, useState } from "react";
import { FeedbackDialogBody } from "@/components/feedback/feedback-dialog";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -12,7 +18,14 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { authClient } from "@/lib/auth/client";
import { getAvatarSrc } from "@/lib/pagadores/utils"; 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 = { type TopbarUserProps = {
user: { user: {
@@ -25,58 +38,109 @@ type TopbarUserProps = {
}; };
export function TopbarUser({ user, pagadorAvatarUrl }: TopbarUserProps) { export function TopbarUser({ user, pagadorAvatarUrl }: TopbarUserProps) {
const router = useRouter();
const [logoutLoading, setLogoutLoading] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const avatarSrc = useMemo(() => { const avatarSrc = useMemo(() => {
if (pagadorAvatarUrl) return getAvatarSrc(pagadorAvatarUrl); if (pagadorAvatarUrl) return getAvatarSrc(pagadorAvatarUrl);
if (user.image) return user.image; if (user.image) return user.image;
return getAvatarSrc(null); return getAvatarSrc(null);
}, [user.image, pagadorAvatarUrl]); }, [user.image, pagadorAvatarUrl]);
async function handleLogout() {
await authClient.signOut({
fetchOptions: {
onSuccess: () => router.push("/login"),
onRequest: () => setLogoutLoading(true),
onResponse: () => setLogoutLoading(false),
},
});
}
return ( return (
<DropdownMenu> <Dialog open={feedbackOpen} onOpenChange={setFeedbackOpen}>
<DropdownMenuTrigger asChild> <DropdownMenu>
<button <DropdownMenuTrigger asChild>
type="button" <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" 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} <Image
alt={user.name} src={avatarSrc}
width={32} alt={`Avatar de ${user.name}`}
height={32} width={40}
className="size-8 rounded-full object-cover" height={40}
/> className="size-10 rounded-full object-cover"
</button> />
</DropdownMenuTrigger> </button>
<DropdownMenuContent align="end" className="w-60 p-2" sideOffset={10}> </DropdownMenuTrigger>
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2"> <DropdownMenuContent align="end" className="w-60 p-2" sideOffset={10}>
<Image <DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
src={avatarSrc} <Image
alt={user.name} src={avatarSrc}
width={36} alt={user.name}
height={36} width={36}
className="size-9 rounded-full object-cover shrink-0" 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> <div className="flex flex-col min-w-0">
<span className="text-xs text-muted-foreground truncate"> <span className="text-sm font-medium truncate">{user.name}</span>
{user.email} <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> </span>
</div> </div>
</DropdownMenuLabel> </DropdownMenuContent>
<DropdownMenuSeparator /> </DropdownMenu>
<div className="flex flex-col gap-1 pt-1"> <FeedbackDialogBody onClose={() => setFeedbackOpen(false)} />
<Link </Dialog>
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>
); );
} }

View File

@@ -1,4 +1,4 @@
import { RiArrowDownBoxFill } from "@remixicon/react"; import { RiArrowDropDownLine } from "@remixicon/react";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"; import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
@@ -73,8 +73,8 @@ function NavigationMenuTrigger({
{...props} {...props}
> >
{children}{" "} {children}{" "}
<RiArrowDownBoxFill <RiArrowDropDownLine
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180" className="relative top-px ml-1 size-4 text-primary transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
</NavigationMenuPrimitive.Trigger> </NavigationMenuPrimitive.Trigger>

View File

@@ -1,7 +1,13 @@
"use server"; "use server";
import { and, eq, lt, sql } from "drizzle-orm"; import { and, eq, lt, ne, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos } from "@/db/schema"; import {
cartoes,
categorias,
faturas,
lancamentos,
orcamentos,
} from "@/db/schema";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas"; import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
@@ -16,15 +22,28 @@ export type DashboardNotification = {
status: NotificationType; status: NotificationType;
amount: number; amount: number;
period?: string; period?: string;
showAmount: boolean; // Controla se o valor deve ser exibido no card showAmount: boolean;
};
export type BudgetStatus = "exceeded" | "critical";
export type BudgetNotification = {
id: string;
categoryName: string;
budgetAmount: number;
spentAmount: number;
usedPercentage: number;
status: BudgetStatus;
}; };
export type DashboardNotificationsSnapshot = { export type DashboardNotificationsSnapshot = {
notifications: DashboardNotification[]; notifications: DashboardNotification[];
totalCount: number; totalCount: number;
budgetNotifications: BudgetNotification[];
}; };
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
const BUDGET_CRITICAL_THRESHOLD = 80;
/** /**
* Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento * Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento
@@ -97,13 +116,11 @@ function parseUTCDate(dateString: string): Date {
function isOverdue(dueDate: string, today: Date): boolean { function isOverdue(dueDate: string, today: Date): boolean {
const due = parseUTCDate(dueDate); const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due); const dueNormalized = normalizeDate(due);
return dueNormalized < today; return dueNormalized < today;
} }
/** /**
* Verifica se uma data vence nos próximos X dias (incluindo hoje) * Verifica se uma data vence nos próximos X dias (incluindo hoje)
* Exemplo: Se hoje é dia 4 e daysThreshold = 5, retorna true para datas de 4 a 8
*/ */
function isDueWithinDays( function isDueWithinDays(
dueDate: string, dueDate: string,
@@ -112,25 +129,21 @@ function isDueWithinDays(
): boolean { ): boolean {
const due = parseUTCDate(dueDate); const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due); const dueNormalized = normalizeDate(due);
// Data limite: hoje + daysThreshold dias (em UTC)
const limitDate = new Date(today); const limitDate = new Date(today);
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold); limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
// Vence se está entre hoje (inclusive) e a data limite (inclusive)
return dueNormalized >= today && dueNormalized <= limitDate; return dueNormalized >= today && dueNormalized <= limitDate;
} }
function toNum(value: unknown): number {
if (typeof value === "number") return value;
return Number(value) || 0;
}
/** /**
* Busca todas as notificações do dashboard * Busca todas as notificações do dashboard:
* * - Faturas de cartão atrasadas ou com vencimento próximo
* Regras: * - Boletos não pagos atrasados ou com vencimento próximo
* - Períodos anteriores: TODOS os não pagos (sempre status "atrasado") * - Orçamentos excedidos (≥ 100%) ou críticos (≥ 80%)
* - Período atual: Itens atrasados + os que vencem nos próximos dias (sem mostrar valor)
*
* Status:
* - "overdue": vencimento antes do dia atual (ou qualquer período anterior)
* - "due_soon": vencimento no dia atual ou nos próximos dias
*/ */
export async function fetchDashboardNotifications( export async function fetchDashboardNotifications(
userId: string, userId: string,
@@ -141,8 +154,7 @@ export async function fetchDashboardNotifications(
const adminPagadorId = await getAdminPagadorId(userId); const adminPagadorId = await getAdminPagadorId(userId);
// Buscar faturas pendentes de períodos anteriores // --- Faturas atrasadas (períodos anteriores) ---
// Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados)
const overdueInvoices = await db const overdueInvoices = await db
.select({ .select({
invoiceId: faturas.id, invoiceId: faturas.id,
@@ -171,8 +183,7 @@ export async function fetchDashboardNotifications(
), ),
); );
// Buscar faturas do período atual // --- Faturas do período atual ---
// Usa LEFT JOIN para incluir cartões com lançamentos mesmo sem registro em faturas
const currentInvoices = await db const currentInvoices = await db
.select({ .select({
invoiceId: faturas.id, invoiceId: faturas.id,
@@ -213,13 +224,12 @@ export async function fetchDashboardNotifications(
faturas.paymentStatus, faturas.paymentStatus,
); );
// Buscar boletos não pagos (usando pagadorId direto ao invés de JOIN) // --- Boletos não pagos ---
const boletosConditions = [ const boletosConditions = [
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO), eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false), eq(lancamentos.isSettled, false),
]; ];
if (adminPagadorId) { if (adminPagadorId) {
boletosConditions.push(eq(lancamentos.pagadorId, adminPagadorId)); boletosConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
} }
@@ -235,18 +245,44 @@ export async function fetchDashboardNotifications(
.from(lancamentos) .from(lancamentos)
.where(and(...boletosConditions)); .where(and(...boletosConditions));
// --- Orçamentos do período atual ---
const budgetJoinConditions = [
eq(lancamentos.categoriaId, orcamentos.categoriaId),
eq(lancamentos.userId, orcamentos.userId),
eq(lancamentos.period, orcamentos.period),
eq(lancamentos.transactionType, "Despesa"),
ne(lancamentos.condition, "cancelado"),
];
if (adminPagadorId) {
budgetJoinConditions.push(eq(lancamentos.pagadorId, adminPagadorId));
}
const budgetRows = await db
.select({
orcamentoId: orcamentos.id,
budgetAmount: orcamentos.amount,
categoriaName: categorias.name,
spentAmount: sql<number>`COALESCE(SUM(ABS(${lancamentos.amount})), 0)`,
})
.from(orcamentos)
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
.leftJoin(lancamentos, and(...budgetJoinConditions))
.where(
and(eq(orcamentos.userId, userId), eq(orcamentos.period, currentPeriod)),
)
.groupBy(orcamentos.id, orcamentos.amount, categorias.name);
// =====================
// Processar notificações
// =====================
const notifications: DashboardNotification[] = []; const notifications: DashboardNotification[] = [];
// Processar faturas atrasadas (períodos anteriores) // Faturas atrasadas (períodos anteriores)
for (const invoice of overdueInvoices) { for (const invoice of overdueInvoices) {
if (!invoice.period || !invoice.dueDay) continue; if (!invoice.period || !invoice.dueDay) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay); const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const amount = const amount = toNum(invoice.totalAmount);
typeof invoice.totalAmount === "number"
? invoice.totalAmount
: Number(invoice.totalAmount) || 0;
const notificationId = invoice.invoiceId const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}` ? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`; : `invoice-${invoice.cardId}-${invoice.period}`;
@@ -259,43 +295,28 @@ export async function fetchDashboardNotifications(
status: "overdue", status: "overdue",
amount: Math.abs(amount), amount: Math.abs(amount),
period: invoice.period, period: invoice.period,
showAmount: true, // Mostrar valor para itens de períodos anteriores showAmount: true,
}); });
} }
// Processar faturas do período atual (atrasadas + vencimento iminente) // Faturas do período atual
for (const invoice of currentInvoices) { for (const invoice of currentInvoices) {
if (!invoice.period || !invoice.dueDay) continue; if (!invoice.period || !invoice.dueDay) continue;
const amount = toNum(invoice.totalAmount);
const amount = const transactionCount = toNum(invoice.transactionCount);
typeof invoice.totalAmount === "number"
? invoice.totalAmount
: Number(invoice.totalAmount) || 0;
const transactionCount =
typeof invoice.transactionCount === "number"
? invoice.transactionCount
: Number(invoice.transactionCount) || 0;
const paymentStatus = const paymentStatus =
invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING; invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
// Ignora se não tem lançamentos e não tem registro de fatura
const shouldInclude = const shouldInclude =
transactionCount > 0 || transactionCount > 0 ||
Math.abs(amount) > 0 || Math.abs(amount) > 0 ||
invoice.invoiceId !== null; invoice.invoiceId !== null;
if (!shouldInclude) continue; if (!shouldInclude) continue;
// Ignora se já foi paga
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue; if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay); const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const invoiceIsOverdue = isOverdue(dueDate, today); const invoiceIsOverdue = isOverdue(dueDate, today);
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD); const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue; if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId const notificationId = invoice.invoiceId
@@ -314,11 +335,9 @@ export async function fetchDashboardNotifications(
}); });
} }
// Processar boletos // Boletos
for (const boleto of boletosRows) { for (const boleto of boletosRows) {
if (!boleto.dueDate) continue; if (!boleto.dueDate) continue;
// Converter para string no formato YYYY-MM-DD (UTC)
const dueDate = const dueDate =
boleto.dueDate instanceof Date boleto.dueDate instanceof Date
? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}` ? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
@@ -326,17 +345,11 @@ export async function fetchDashboardNotifications(
const boletoIsOverdue = isOverdue(dueDate, today); const boletoIsOverdue = isOverdue(dueDate, today);
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD); const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const isOldPeriod = boleto.period < currentPeriod; const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod; const isCurrentPeriod = boleto.period === currentPeriod;
const amount = toNum(boleto.amount);
// Período anterior: incluir todos (sempre atrasado)
if (isOldPeriod) { if (isOldPeriod) {
const amount =
typeof boleto.amount === "number"
? boleto.amount
: Number(boleto.amount) || 0;
notifications.push({ notifications.push({
id: `boleto-${boleto.id}`, id: `boleto-${boleto.id}`,
type: "boleto", type: "boleto",
@@ -345,24 +358,15 @@ export async function fetchDashboardNotifications(
status: "overdue", status: "overdue",
amount: Math.abs(amount), amount: Math.abs(amount),
period: boleto.period, period: boleto.period,
showAmount: true, // Mostrar valor para períodos anteriores showAmount: true,
}); });
} } else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
// Período atual: incluir atrasados e os que vencem em breve (sem valor)
else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
const status: NotificationType = boletoIsOverdue ? "overdue" : "due_soon";
const amount =
typeof boleto.amount === "number"
? boleto.amount
: Number(boleto.amount) || 0;
notifications.push({ notifications.push({
id: `boleto-${boleto.id}`, id: `boleto-${boleto.id}`,
type: "boleto", type: "boleto",
name: boleto.name, name: boleto.name,
dueDate, dueDate,
status, status: boletoIsOverdue ? "overdue" : "due_soon",
amount: Math.abs(amount), amount: Math.abs(amount),
period: boleto.period, period: boleto.period,
showAmount: boletoIsOverdue, showAmount: boletoIsOverdue,
@@ -377,8 +381,37 @@ export async function fetchDashboardNotifications(
return a.dueDate.localeCompare(b.dueDate); return a.dueDate.localeCompare(b.dueDate);
}); });
// Orçamentos excedidos e críticos
const budgetNotifications: BudgetNotification[] = [];
for (const row of budgetRows) {
const budgetAmount = toNum(row.budgetAmount);
const spentAmount = toNum(row.spentAmount);
if (budgetAmount <= 0) continue;
const usedPercentage = (spentAmount / budgetAmount) * 100;
if (usedPercentage < BUDGET_CRITICAL_THRESHOLD) continue;
budgetNotifications.push({
id: `budget-${row.orcamentoId}`,
categoryName: row.categoriaName,
budgetAmount,
spentAmount,
usedPercentage,
status: usedPercentage >= 100 ? "exceeded" : "critical",
});
}
// Excedidos primeiro, depois por percentual decrescente
budgetNotifications.sort((a, b) => {
if (a.status === "exceeded" && b.status !== "exceeded") return -1;
if (a.status !== "exceeded" && b.status === "exceeded") return 1;
return b.usedPercentage - a.usedPercentage;
});
return { return {
notifications, notifications,
totalCount: notifications.length, totalCount: notifications.length,
budgetNotifications,
}; };
} }

View File

@@ -1,11 +1,11 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { ptBR } from "date-fns/locale"; import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema"; import { categorias, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common"; import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { generatePeriodRange } from "./utils"; import { generatePeriodRange } from "./utils";
export type CategoryChartData = { export type CategoryChartData = {
@@ -34,14 +34,17 @@ export async function fetchCategoryChartData(
endPeriod: string, endPeriod: string,
categoryIds?: string[], categoryIds?: string[],
): Promise<CategoryChartData> { ): Promise<CategoryChartData> {
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod); const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { months: [], categories: [], chartData: [], allCategories: [] };
}
const whereConditions = [ const whereConditions = [
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods), inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")), or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -49,46 +52,42 @@ export async function fetchCategoryChartData(
), ),
]; ];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) { if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds)); whereConditions.push(inArray(categorias.id, categoryIds));
} }
// Query to get aggregated data by category and period const [rows, allCategoriesRows] = await Promise.all([
const rows = await db db
.select({ .select({
categoryId: categorias.id, categoryId: categorias.id,
categoryName: categorias.name, categoryName: categorias.name,
categoryIcon: categorias.icon, categoryIcon: categorias.icon,
categoryType: categorias.type, categoryType: categorias.type,
period: lancamentos.period, period: lancamentos.period,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`, total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) .where(and(...whereConditions))
.where(and(...whereConditions)) .groupBy(
.groupBy( categorias.id,
categorias.id, categorias.name,
categorias.name, categorias.icon,
categorias.icon, categorias.type,
categorias.type, lancamentos.period,
lancamentos.period, ),
); db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name),
]);
// Fetch all categories for the user (for category selection)
const allCategoriesRows = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
// Map all categories
const allCategories = allCategoriesRows.map( const allCategories = allCategoriesRows.map(
(cat: { id: string; name: string; icon: string | null; type: string }) => ({ (cat: { id: string; name: string; icon: string | null; type: string }) => ({
id: cat.id, id: cat.id,
@@ -98,7 +97,6 @@ export async function fetchCategoryChartData(
}), }),
); );
// Process results into chart format
const categoryMap = new Map< const categoryMap = new Map<
string, string,
{ {
@@ -110,7 +108,6 @@ export async function fetchCategoryChartData(
} }
>(); >();
// Process each row
for (const row of rows) { for (const row of rows) {
const amount = Math.abs(toNumber(row.total)); const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } = const { categoryId, categoryName, categoryIcon, categoryType, period } =
@@ -126,37 +123,31 @@ export async function fetchCategoryChartData(
}); });
} }
const categoryItem = categoryMap.get(categoryId)!; categoryMap.get(categoryId)!.dataByPeriod.set(period, amount);
categoryItem.dataByPeriod.set(period, amount);
} }
// Build chart data
const chartData = periods.map((period) => { const chartData = periods.map((period) => {
const [year, month] = period.split("-"); const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1); const date = new Date(Number.parseInt(year, 10), Number.parseInt(month, 10) - 1, 1);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase(); const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
const dataPoint: { month: string; [key: string]: number | string } = { const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel, month: monthLabel,
}; };
// Add data for each category
for (const category of categoryMap.values()) { for (const category of categoryMap.values()) {
const value = category.dataByPeriod.get(period) ?? 0; dataPoint[category.name] = category.dataByPeriod.get(period) ?? 0;
dataPoint[category.name] = value;
} }
return dataPoint; return dataPoint;
}); });
// Generate month labels
const months = periods.map((period) => { const months = periods.map((period) => {
const [year, month] = period.split("-"); const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1); const date = new Date(Number.parseInt(year, 10), Number.parseInt(month, 10) - 1, 1);
return format(date, "MMM", { locale: ptBR }).toUpperCase(); return format(date, "MMM", { locale: ptBR }).toUpperCase();
}); });
// Build categories array
const categories = Array.from(categoryMap.values()).map((cat) => ({ const categories = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id, id: cat.id,
name: cat.name, name: cat.name,
@@ -164,10 +155,5 @@ export async function fetchCategoryChartData(
type: cat.type, type: cat.type,
})); }));
return { return { months, categories, chartData, allCategories };
months,
categories,
chartData,
allCategories,
};
} }

View File

@@ -1,9 +1,9 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema"; import { categorias, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common"; import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import type { import type {
CategoryReportData, CategoryReportData,
CategoryReportFilters, CategoryReportFilters,
@@ -28,11 +28,16 @@ export async function fetchCategoryReport(
// Generate all periods in the range // Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod); const periods = generatePeriodRange(startPeriod, endPeriod);
const adminPagadorId = await getAdminPagadorId(userId);
if (!adminPagadorId) {
return { categories: [], periods, totals: new Map(), grandTotal: 0 };
}
// Build WHERE conditions // Build WHERE conditions
const whereConditions = [ const whereConditions = [
eq(lancamentos.userId, userId), eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, adminPagadorId),
inArray(lancamentos.period, periods), inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")), or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or( or(
isNull(lancamentos.note), isNull(lancamentos.note),
@@ -56,7 +61,6 @@ export async function fetchCategoryReport(
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`, total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
}) })
.from(lancamentos) .from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions)) .where(and(...whereConditions))
.groupBy( .groupBy(

View File

@@ -72,6 +72,7 @@
"next": "16.1.6", "next": "16.1.6",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pg": "8.18.0", "pg": "8.18.0",
"radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",
"react-dom": "19.2.4", "react-dom": "19.2.4",

491
pnpm-lock.yaml generated
View File

@@ -143,6 +143,9 @@ importers:
pg: pg:
specifier: 8.18.0 specifier: 8.18.0
version: 8.18.0 version: 8.18.0
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: react:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4 version: 19.2.4
@@ -1089,6 +1092,19 @@ packages:
'@radix-ui/primitive@1.1.3': '@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accessible-icon@1.1.7':
resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-accordion@1.2.12': '@radix-ui/react-accordion@1.2.12':
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
peerDependencies: peerDependencies:
@@ -1128,6 +1144,32 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-aspect-ratio@1.1.7':
resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-avatar@1.1.10':
resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-avatar@1.1.11': '@radix-ui/react-avatar@1.1.11':
resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
peerDependencies: peerDependencies:
@@ -1189,6 +1231,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-context@1.1.2': '@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies: peerDependencies:
@@ -1277,6 +1332,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-form@0.1.8':
resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-hover-card@1.1.15': '@radix-ui/react-hover-card@1.1.15':
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
peerDependencies: peerDependencies:
@@ -1299,6 +1367,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-label@2.1.7':
resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-label@2.1.8': '@radix-ui/react-label@2.1.8':
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
peerDependencies: peerDependencies:
@@ -1325,6 +1406,58 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-menubar@1.1.16':
resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-navigation-menu@1.2.14':
resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-one-time-password-field@0.1.8':
resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-password-toggle-field@0.1.3':
resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15': '@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies: peerDependencies:
@@ -1403,6 +1536,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-progress@1.1.7':
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-progress@1.1.8': '@radix-ui/react-progress@1.1.8':
resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
peerDependencies: peerDependencies:
@@ -1468,6 +1614,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-separator@1.1.7':
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-separator@1.1.8': '@radix-ui/react-separator@1.1.8':
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
peerDependencies: peerDependencies:
@@ -1481,6 +1640,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-slider@1.3.6':
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3': '@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies: peerDependencies:
@@ -1525,6 +1697,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-toast@1.2.15':
resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-toggle-group@1.1.11': '@radix-ui/react-toggle-group@1.1.11':
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
peerDependencies: peerDependencies:
@@ -1551,6 +1736,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-toolbar@1.1.11':
resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8': '@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies: peerDependencies:
@@ -2503,6 +2701,19 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
radix-ui@1.4.3:
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
raf@3.4.1: raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
@@ -3314,6 +3525,15 @@ snapshots:
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -3354,6 +3574,28 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4)
@@ -3417,6 +3659,20 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
@@ -3502,6 +3758,20 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -3526,6 +3796,15 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -3561,6 +3840,82 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -3640,6 +3995,16 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4)
@@ -3731,6 +4096,15 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -3740,6 +4114,25 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
@@ -3785,6 +4178,26 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -3811,6 +4224,21 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -4573,6 +5001,69 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
raf@3.4.1: raf@3.4.1:
dependencies: dependencies:
performance-now: 2.1.0 performance-now: 2.1.0