Dashboard com login e Next.js 15

19/09/202565 min
next

Capítulo 1 — Iniciando o projeto Next.js

Iniciei o projeto transactions.app com Next.js, as escolhas iniciais, e o defini o database.

Comandos executados

bash
npx create-next-app@latest transactions.app
# Respondi às perguntas:
# - Use TypeScript? Yes
# - Linter: ESLint
# - Tailwind? No
# - Colocar código dentro de src/? Yes
# - Usar App Router? Yes
# - Turbopack? Yes (ou conforme sua escolha)
# - Custom import alias? Yes (opcional)
Usei src/db/transactions.json como database

Capítulo 2 — Configurando styled-components no Next.js

Styled-components precisa de configuração extra no Next.js para SSR. Neste capítulo explico o setup, o next.config.js e o registry para o App Router.

Instalação

bash
npm install styled-components
npm install -D @types/styled-components

next.config.js

Ative o compilador para styled-components:
javascript
// next.config.js
module.exports = {
  compiler: {
    styledComponents: true,
  },
};

Registry para App Router (ServerStyleSheet)

Para App Router usamos um pequeno registry que injeta as tags de estilos do server. Criei src/lib/registry.tsx:
typescript
// src/lib/registry.tsx
"use client";

import React, { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
    return <>{styles}</>;
  });

  if (typeof window !== "undefined") return <>{children}</>;

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  );
}

Integrando no layout root

No app/layout.tsx (App Router) envolva a árvore com o registry:
typescript
// app/layout.tsx
import StyledComponentsRegistry from "@/lib/registry";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>
          {children}
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

Capítulo 3 — Criando e aplicando um Theme com styled-components

Antes de construir componentes, defino um theme global para cores.

Tipos para styled-components

Arquivo: src/types/styled.d.ts
typescript
import 'styled-components';

declare module 'styled-components' {
  export interface DefaultTheme {
    colors: {
      background: string;
      surface: string;
      primary: string;
      secondary: string;
      accent: string;
      text: string;
      textSecondary: string;
      border: string;
    };
  }
}

Theme (light & dark)

Arquivo: src/styles/theme/theme.ts
typescript
import { DefaultTheme } from 'styled-components';

export const lightTheme: DefaultTheme = {
  colors: {
    background: '#f4f4f4',
    surface: '#ffffff',
    primary: '#3f20ba',
    secondary: '#00c2ff',
    accent: '#ffcc00',
    text: '#222222',
    textSecondary: '#666666',
    border: '#eaeaea',
  },
};

export const darkTheme: DefaultTheme = {
  colors: {
    background: '#19092e',
    surface: '#222222',
    primary: '#3f20ba',
    secondary: '#00c2ff',
    accent: '#ffcc00',
    text: '#ffffff',
    textSecondary: '#eaeaea',
    border: '#3f20ba',
  },
};

GlobalStyle (adicionado)

Crie o arquivo src/styles/GlobalStyle.tsx com suas regras base e reset.
typescript
// src/styles/GlobalStyle.tsx
'use client';

import { createGlobalStyle } from "styled-components";

export const GlobalStyle = createGlobalStyle`
  html,
  body {
    max-width: 100vw;
    overflow-x: hidden;
    color: ${({ theme }) => theme.colors.text};
    background: ${({ theme }) => theme.colors.background};
    font-family: Arial, Helvetica, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  * {
    box-sizing: border-box;
    padding: 0;
    margin: 0;
  }

  a {
    color: inherit;
    text-decoration: none;
  }
`;

Provedor de tema com persistência via cookie

Instalei js-cookie para guardar a preferência do tema e lucide para ícones:
bash
npm install js-cookie
npm install lucide-react
npm i -D @types/js-cookie
Criei src/theme/themeProvider.tsx (client component
typescript
'use client';

import React, { useState, ReactNode } from 'react';
import { ThemeProvider } from 'styled-components';
import Cookies from 'js-cookie';
import { GlobalStyle } from '@/styles/GlobalStyle';
import { darkTheme, lightTheme } from '@/styles/theme';
import { Moon, Sun } from 'lucide-react';
import { BtnChangeTheme } from './style';

interface ThemeProviderProps {
  children: ReactNode;
  initialTheme: 'light' | 'dark';
}

export const CustomThemeProvider = ({ children, initialTheme }: ThemeProviderProps) => {
  const [theme, setTheme] = useState<'light' | 'dark'>(initialTheme);

  const toggleTheme = (newTheme: 'light' | 'dark') => {
    setTheme(newTheme);
    Cookies.set('theme', newTheme);
  };

  return (
    <ThemeProvider theme={theme === 'light' ? lightTheme : darkTheme}>
      <GlobalStyle />
      <BtnChangeThemeonClick={() => (theme === 'light' ? toggleTheme('dark') : toggleTheme('light'))}
        aria-label="Toggle theme"
      >
        {theme === 'light' ? <Moon /> : <Sun />}
      </BtnChangeTheme>
      {children}
    </ThemeProvider>
  );
};
BtnChangeTheme em src/theme/style.ts:
typescript
import styled from "styled-components";

export const BtnChangeTheme = styled.button`
  position: fixed;
  top: 10px;
  right: 20px;

  display: flex;
  align-items: center;
  justify-content: center;

  width: 40px;
  height: 40px;

  border: none;
  border-radius: 50%;

  background-color: ${({ theme }) => theme.colors.background};
  color: ${({ theme }) => theme.colors.primary};

  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);

  cursor: pointer;
  transition: all 0.25s ease;

  &:hover {
    transform: scale(1.05) rotate(5deg);
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
  }

  &:active {
    transform: scale(0.95);
  }

  &:focus-visible {
    outline: 2px solid ${({ theme }) => theme.colors.primary};
    outline-offset: 3px;
  }

  @media (max-width: 600px) {
    top: 12px;
    right: 12px;
    width: 36px;
    height: 36px;
  }
`;

Carregando o theme inicial no layout (server-side cookie)

No app/layout.tsx peguei o cookie e passei ao CustomThemeProvider:
typescript
import StyledComponentsRegistry from "@/lib/registry";
import { CustomThemeProvider } from "@/theme/themeProvider";
import { cookies } from 'next/headers';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const theme = cookieStore.get('theme')?.value === 'dark' ? 'dark' : 'light';

  return (
    <html>
      <body>
        <StyledComponentsRegistry>
          <CustomThemeProvider initialTheme={theme}>
            {children}
          </CustomThemeProvider>
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

Capítulo 4: Tela de Login — Implementação e UI simples

login usando react-hook-form, styled-components e uma rota API que retorna um JWT. Também usar UI mínima (Input e Button), validação, feedback com react-hot-toast e onde colocar o <Toaster />.

Instalações (comandos)

bash
# Form handling
npm install react-hook-form

# Toasts
npm install react-hot-toast

# JWT (para gerar token no server - dev only)
npm install jsonwebtoken

# Se for usar cookies (opcional)
npm install js-cookie

Estrutura usada

  • src/app/page.tsxlogin
  • src/ui/Input.tsx — componente input reutilizável
  • src/ui/Button.tsx — componente botão reutilizável
  • src/app/api/login/route.ts — rota API que valida credenciais e retorna JWT
  • src/theme/themeProvider.tsx ou app/layout.tsx — dever conter <Toaster /> para os toasts

Implementação do formulário (com react-hook-form)

Arquivo: src/app/page.tsx
Componente LoginPage
typescript
"use client";

import React, { useState } from "react";
import { useForm } from "react-hook-form";
import {
  Page,
  Card,
  Brand,
  Subtitle,
  Form,
  Label,
  Row,
  Remember,
  ErrorText,
} from "./styled";
import { Input } from "@/ui/Input";
import { Button } from "@/ui/Button";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";

type FormData = {
  email: string;
  password: string;
  remember: boolean;
};

export default function 
LoginPage
() {
  const router = useRouter();
  const [loading, setLoading] = useState(false);

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    defaultValues: {
      email: "",
      password: "",
      remember: true,
    },
  });

  const onSubmit = async (data: FormData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });

      const response = await res.json();

      if (response.error) {
        toast.error(response.error);
      } else if (response.token) {
        Cookies.set("token", response.token);
        router.push("/dashboard");
      }
    } catch (err) {
      console.error(err);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Page>
      <Card aria-live="polite">
        <Brand>MinhaApp</Brand>
        <Subtitle>Entre com sua conta para continuar</Subtitle>

        <Form onSubmit={handleSubmit(onSubmit)} noValidate>
          <div>
            <Label htmlFor="email">E-mail ou usuário</Label>
            <Input
              id="email"
              type="email"
              placeholder="seu@exemplo.com"
              autoComplete="username"
              aria-invalid={!!errors.email}
              {...register("email", {
                required: "Informe e-mail ou usuário",
              })}
            />
            {errors.email && <ErrorText>{errors.email.message}</ErrorText>}
          </div>

          <div>
            <Label htmlFor="password">Senha</Label>
            <Input
              id="password"
              type="password"
              placeholder="••••••••"
              autoComplete="current-password"
              aria-invalid={!!errors.password}
              {...register("password", {
                required: "Informe a senha",
                minLength: {
                  value: 6,
                  message: "Senha deve ter ao menos 6 caracteres",
                },
              })}
            />
            {errors.password && (
              <ErrorText>{errors.password.message}</ErrorText>
            )}
          </div>

          <Row>
            <Remember>
              <input id="remember" type="checkbox" {...register("remember")} />
              <label htmlFor="remember">Manter conectado</label>
            </Remember>

            <a
              href="#"
              onClick={(ev) => ev.preventDefault()}
              style={{ fontSize: 13 }}
            >
              Esqueceu a senha?
            </a>
          </Row>

          <Button type="submit" disabled={loading}>
            {loading ? "Entrando..." : "Entrar"}
          </Button>
        </Form>
      </Card>
    </Page>
  );
}

Estilos do componente de login

Arquivo: src/app/styled.ts :
typescript
import styled from "styled-components";

export const Page = styled.main`
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
`;

export const Card = styled.section`
  width: 100%;
  max-width: 420px;
  background: ${({ theme }) => theme.colors.primary};
  color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 10px 30px rgba(25, 9, 46, 0.12);
  padding: 28px;
  border: 2px solid ${({ theme }) => theme.colors.border};
`;

export const Brand = styled.h1`
  margin: 0 0 8px 0;
  font-size: 20px;
  letter-spacing: -0.2px;
  color: #ffffff;
`;

export const Subtitle = styled.p`
  margin: 0 0 18px 0;
  color: #cacacaca;
  font-size: 14px;
`;

export const Form = styled.form`
  display: grid;
  gap: 12px;
`;

export const Label = styled.label`
  display: block;
  font-size: 13px;
  color: #cacacaca;
  margin-bottom: 6px;
`;

export const Row = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-between;
`;

export const Remember = styled.div`
  display: flex;
  gap: 8px;
  align-items: center;
  font-size: 13px;
  color: #cacacaca;
`;

export const ErrorText = styled.p`
  margin: 0;
  color: #c0392b;
  font-size: 13px;
`;

Componentes de UI simples

src/ui/Button/index.tsx
typescript
import { ButtonHTMLAttributes } from "react";
import { StyledButton } from "./styled";

type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: "primary" | "ghost";
};

export const Button: React.FC<Props> = ({ children, ...rest }) => {
  return <StyledButton {...rest}>{children}</StyledButton>;
};
src/ui/Button/styled.ts
typescript
import styled from "styled-components";

export const StyledButton = styled.button`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 10px 14px;
  border-radius: 10px;
  border: none;
  cursor: pointer;
  font-weight: 600;
  transition: transform 0.4s normal;
  background: ${({ theme }) => theme.colors.secondary};
  color: #ffffff;
  box-shadow: 0 0px 10px ${({ theme }) => theme.colors.primary};
  &:hover {
    transform: scale(1.05);
  }

  &:active {
    transform: translateY(0);
  }
  &:disabled {
    opacity: 0.6;
    cursor: not-allowed;
    transform: none;
  }
`;
src/ui/Input/index.tsx
typescript
import React, { InputHTMLAttributes } from "react";
import { StyledInput } from "./styled";

type Props = InputHTMLAttributes<HTMLInputElement>;

export const Input: React.FC<Props> = (props) => {
  return <StyledInput {...props} />;
};
src/ui/Input/styled.tsx
typescript
import React, { InputHTMLAttributes } from "react";
import { StyledInput } from "./styled";

type Props = InputHTMLAttributes<HTMLInputElement>;

export const Input: React.FC<Props> = (props) => {
  return <StyledInput {...props} />;
};

Rota API de login (server) — app/api/login/route.ts

typescript
import { NextResponse } from "next/server";
import jwt from "jsonwebtoken";

const SECRET_KEY = process.env.JWT_SECRET || "YM";

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { email, password } = body;

    if (!email || !password) {
      return NextResponse.json(
        { error: "Email e senha são obrigatórios" },
        { status: 400 }
      );
    }

    if (email === "ym@email.com" && password === "123456") {
      const token = jwt.sign({ email }, SECRET_KEY, { expiresIn: "8h" });

      return NextResponse.json({ message: "Login bem-sucedido", token });
    } else {
      return NextResponse.json(
        { error: "Credenciais inválidas" },
        { status: 401 }
      );
    }
  } catch (err) {
    console.error(err);
    return NextResponse.json({ error: "Erro no servidor" }, { status: 500 });
  }
}

Toaster (react-hot-toast)

Adicione <Toaster /> no topo da aplicação (ex.: em CustomThemeProvider ou app/layout.tsx).
typescript
// em src/theme/themeProvider.tsx ou app/layout.tsx (client)
import { Toaster } from "react-hot-toast";

return (
  <ThemeProvider theme={...}>
    <GlobalStyle />
    <BtnChangeTheme ... />
    <Toaster position="bottom-center" />
    {children}
  </ThemeProvider>
);

Capítulo 5 – Protegendo Rotas e Construindo o Dashboard

Agora que já implementamos o login, a persistência do token e o redirecionamento automático, chegou o momento de criar o Dashboard. Este capítulo vai detalhar toda a estrutura da página protegida e seus componentes.

5.1 Redirecionamento automático na tela de Login

Para melhorar a experiência do usuário, adicionamos na tela de login um redirecionamento automático caso o token já exista nos cookies:
typescript
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Cookies from "js-cookie";

export default function LoginCard() {
  const router = useRouter();
  const authToken = Cookies.get("token");

  useEffect(() => {
    if (authToken) {
      router.push("/dashboard");
    }
  }, [authToken]);

  return (
    // ... restante do código de login
  );
}
✅ Assim, quem já está logado é enviado diretamente para o Dashboard.

5.2 Protegendo a rota do Dashboard

No arquivo src/dashboard/page.tsx, criamos uma função AuthValidation que valida o token JWT antes de renderizar a página:
typescript
import { cookies } from "next/headers";
import jwt from "jsonwebtoken";
import { redirect } from "next/navigation";
import DashboardPage from "./components/Dashboard";

const SECRET_KEY = process.env.JWT_SECRET || "YM";

export const AuthValidation = (token?: string) => {
  try {
    if (!token) {
      redirect("/");
    }
    const decoded = jwt.verify(token, SECRET_KEY);
    return decoded;
  } catch (err) {
    console.error(err);
    cookieStore.delete("token");
    redirect("/");
  }
};

export default async function Dashboard() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  AuthValidation(token);

  return <DashboardPage />;
}
✅ Isso garante que apenas usuários autenticados consigam acessar o Dashboard.

5.3 Estrutura principal do Dashboard

O Dashboard é dividido em três partes principais:
  1. Sidebar – navegação lateral e logout
  2. Header – topo da página com título
  3. Cards – exibição de dados resumidos ou visualizações

a) Sidebar

A Sidebar permite navegar e fazer logout. Ela também pode ser colapsada para ganhar mais espaço na tela:
typescript
import { useState } from "react";
import styled from "styled-components";
import {
  Home,
  SquareChevronRight,
  SquareChevronLeft,
  LogOut,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Cookies from "js-cookie";

const SidebarContainer = styled.div<{ $collapsed: boolean }>`
  width: ${({ $collapsed }) => ($collapsed ? "60px" : "150px")};
  height: 100vh;
  background-color: ${({ theme }) => theme.colors.primary};
  color: #ffffff;
  display: flex;
  flex-direction: column;
  padding: 20px 10px;
  transition: width 0.3s ease;
`;

const ToggleButton = styled.button<{ $collapsed: boolean }>`
  background: none;
  border: none;
  color: #ffffff;
  font-size: 1.5rem;
  cursor: pointer;
  margin-bottom: 20px;
  align-self: ${({ $collapsed }) => ($collapsed ? "center" : "flex-end")};
`;

const SidebarItem = styled.div<{ $collapsed: boolean }>`
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 15px 0;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 1;
  transition: all 0.2s ease;
  justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "center")};
  span {
    display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")};
  }

  &:hover {
    opacity: 0.8;
  }
`;

export const Sidebar = () => {
  const router = useRouter();
  const [collapsed, setCollapsed] = useState(false);

  return (
    <SidebarContainer $collapsed={collapsed}>
      <ToggleButton
        onClick={() => setCollapsed(!collapsed)}
        $collapsed={collapsed}
      >
        {collapsed ? <SquareChevronRight /> : <SquareChevronLeft />}
      </ToggleButton>
      <SidebarItem $collapsed={collapsed}>
        <Home size={20} /> <span>Home</span>
      </SidebarItem>

      <SidebarItem
        onClick={() => {
          Cookies.remove("token");
          router.push("/");
        }}
        $collapsed={collapsed}
        style={{ marginTop: "auto" }}
      >
        <LogOut size={20} /> <span>Logout</span>
      </SidebarItem>
    </SidebarContainer>
  );
};
📌 Explicação:
  • $collapsed controla a largura da Sidebar.
  • ToggleButton permite expandir ou colapsar.

b) Header

O Header é simples, mas flexível para adicionar controles ou informações globais:
typescript
import styled from "styled-components";

const HeaderContainer = styled.div`
  height: 60px;
  width: 100%;
  background-color: ${({ theme }) => theme.colors.surface};
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  color: ${({ theme }) => theme.colors.text};
`;

export const Header = () => (
  <HeaderContainer>
    <div>Dashboard</div>
  </HeaderContainer>
);

c) Card

Cada Card representa uma seção de dados dentro do Dashboard:
typescript
import styled from "styled-components";

const CardContainer = styled.div`
  background-color: ${({ theme }) => theme.colors.surface};
  padding: 20px;
  border-radius: 10px;
  box-shadow: 0 0 10px rgba(0,0,0,0.1);
  margin-bottom: 20px;
`;

export const Card = ({ children }: { children: React.ReactNode }) => (
  <CardContainer>{children}</CardContainer>
);

d) Dashboard Container

Por fim, unimos Sidebar, Header e Cards no layout final:
typescript
"use client";

import styled from "styled-components";
import { Sidebar } from "./Sidebar";
import { Header } from "./Header";
import { Card } from "./Card";

const DashboardContainer = styled.div` display: flex; `;
const MainContent = styled.div`
  flex: 1;
  background-color: ${({ theme }) => theme.colors.background};
  min-height: 100vh;
  display: flex;
  flex-direction: column;
`;
const ContentWrapper = styled.div`
  padding: 20px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
`;

export default function DashboardPage() {
  return (
    <DashboardContainer>
      <Sidebar />
      <MainContent>
        <Header />
        <ContentWrapper>
          <Card>Card 1</Card>
          <Card>Card 2</Card>
          <Card>Card 3</Card>
          <Card>Card 4</Card>
        </ContentWrapper>
      </MainContent>
    </DashboardContainer>
  );
}
📌 Explicação:
  • DashboardContainer organiza Sidebar e conteúdo principal.
  • MainContent engloba Header e Cards.
  • ContentWrapper usa grid responsivo para os Cards.

Capítulo 6 — Dashboard

Estrutura geral (arquivos mencionados)

  • src/ultis/fomartCurrency.ts
  • src/ultis/parseAmount.ts
  • src/ui/table/index.ts
  • src/ui/table/styled.ts
  • src/types/transaction.ts
  • src/app/dashbord/page.tsx
  • src/app/componets/dashbord/index.tsx
  • src/app/componets/dashbord/styled.ts
  • componentes atualizados: Card, Charts, Header, Sidebar

Utils

src/ultis/fomartCurrency.ts
typescript
export function formatCurrency(value: number, locale = "pt-BR", currency = "BRL") {
  return value.toLocaleString(locale, {
    style: "currency",
    currency,
    minimumFractionDigits: 2,
  });
}
Descrição: função que formata um number para o formato de moeda usando toLocaleString com pt-BR e BRL por padrão.
src/ultis/parseAmount.ts
typescript
export function parseAmount(amountStr: string): number {
  const raw = parseInt(amountStr, 10) || 0;
  return raw / 100; 
}
Descrição: converte uma string que representa centavos para um número em reais (divide por 100).

UI — Tabela de transações

src/ui/table/index.ts
typescript
import { useState } from "react";
import {
  PageButton,
  PaginationWrapper,
  Table,
  TableWrapper,
  Td,
  Th,
} from "./styled";
import { ITransaction } from "@/types/transaction";

interface TransactionsTableProps {
  data: ITransaction[];
  itemsPerPage?: number;
}

export const TransactionsTable = ({
  data,
  itemsPerPage = 10,
}: TransactionsTableProps) => {
  const [currentPage, setCurrentPage] = useState(1);

  const totalPages = Math.ceil(data.length / itemsPerPage);
  const startIndex = (currentPage - 1) * itemsPerPage;
  const currentItems = data.slice(startIndex, startIndex + itemsPerPage);

  function getPaginationPages(current: number, total: number, maxVisible = 5) {
    const pages = [];

    if (total <= maxVisible) {
      for (let i = 1; i <= total; i++) pages.push(i);
    } else {
      pages.push(1);

      const start = Math.max(2, current - 1);
      const end = Math.min(total - 1, current + 1);

      if (start > 2) pages.push("...");
      for (let i = start; i <= end; i++) pages.push(i);
      if (end < total - 1) pages.push("...");

      pages.push(total);
    }

    return pages;
  }

  const pages = getPaginationPages(currentPage, totalPages);

  return (
    <TableWrapper>
      <Table>
        <thead>
          <tr>
            <Th>Date</Th>
            <Th>Account</Th>
            <Th>Type</Th>
            <Th>Amount</Th>
          </tr>
        </thead>
        <tbody>
          {currentItems.map((tx, index) => (
            <tr key={index}>
              <Td>{new Date(Number(tx.date)).toLocaleDateString()}</Td>
              <Td>{tx.account}</Td>
              <Td
                style={{
                  color: tx.transaction_type === "deposit" ? "green" : "red",
                }}
              >
                {tx.transaction_type}
              </Td>
              <Td>
                {Number(tx.amount).toLocaleString("en-US", {
                  style: "currency",
                  currency: tx.currency.toUpperCase(),
                })}
              </Td>
            </tr>
          ))}
        </tbody>
      </Table>

      <PaginationWrapper>
        <PageButton
          onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))}
          disabled={currentPage === 1}
        >
          &lt;
        </PageButton>

        {pages.map((page, index) =>
          page === "..." ? (
            <span key={index}>...</span>
          ) : (
            <PageButton
              key={index}
              $active={page === currentPage}
              onClick={() => setCurrentPage(Number(page))}
            >
              {page}
            </PageButton>
          )
        )}

        <PageButton
          onClick={() => setCurrentPage(Math.min(currentPage + 1, totalPages))}
          disabled={currentPage === totalPages}
        >
          &gt;
        </PageButton>
      </PaginationWrapper>
    </TableWrapper>
  );
};
Descrição: componente de tabela que implementa paginação e exibe as colunas: Date, Account, Type, Amount.
src/ui/table/styled.ts
typescript
import styled from "styled-components";

export const TableWrapper = styled.div`
  width: 100%;
  margin-top: 40px;
  border-radius: 10px;
  overflow: hidden;
  border: 1px solid #ddd;
  grid-column: 1 / -1;
  width: 100%;
  margin-top: 0px;
  overflow-x: auto;
`;

export const Table = styled.table`
  width: 100%;
  border-collapse: collapse;
`;

export const Th = styled.th`
  text-align: left;
  padding: 12px;
  background-color: #6200ee;
  color: white;
  border-bottom: 1px solid #ddd;
`;

export const Td = styled.td`
  padding: 12px;
  border-bottom: 1px solid #ddd;
  background-color: ${({ theme }) => theme.colors.surface};
`;

export const PaginationWrapper = styled.div`
  display: flex;
  justify-content: center;
  margin: 20px 0px;
  gap: 8px;
`;

export const PageButton = styled.button<{ $active?: boolean }>`
  padding: 6px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background-color: ${({ $active }) => ($active ? "#6200ee" : "white")};
  color: ${({ $active }) => ($active ? "white" : "#333")};
  cursor: pointer;

  &:hover {
    background-color: ${({ $active }) => ($active ? "#6200ee" : "#f0f0f0")};
  }
`;
Descrição: estilos para a tabela e paginação usando styled-components.

Tipagem

src/types/transaction.ts
typescript
export interface ITransaction {
  date: number;
  amount: string;
  transaction_type: TransactionType;
  currency: Currency;
  account: string;
  industry: string;
  state: string;
}

enum Currency {
  Brl = "brl",
}

enum TransactionType {
  Deposit = "deposit",
  Withdraw = "withdraw",
}
Descrição: interface que descreve o formato das transações utilizadas pelo dashboard.

Rota do Dashboard

src/app/dashbord/page.tsx
typescript
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import DashboardPage from "@/components/dashboard/Dashboard";
import transactions from "@/db/transactions.json";
import { ITransaction } from "@/types/transaction";
import { AuthValidation } from "@/utils/auth";

export default async function Dashboard() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  const auth = AuthValidation(token);
  if (!auth) {
    redirect("/");
  }

  const data: ITransaction[] = transactions as ITransaction[];
  const orderedData = data.sort((a, b) => b.date - a.date);

  return <DashboardPage data={orderedData} />;
}
Descrição: página do Next.js que valida autenticação via cookie e retorna o componente DashboardPage com os dados ordenados por data.

Componentes do Dashboard

Card
typescript
import styled from "styled-components";

const CardContainer = styled.div`
  background-color: ${({ theme }) => theme.colors.surface};
  padding: 20px;
  border-radius: 10px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
`;

export const Card = ({ children }: { children: React.ReactNode }) => (
  <CardContainer>{children}</CardContainer>
);
Descrição: componente simples de container para exibir KPIs no dashboard.
Charts
typescript
import React, { useMemo } from "react";
import {
  BarChart as ReBarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  LineChart as ReLineChart,
  Line,
  ResponsiveContainer,
} from "recharts";
import type { ITransaction } from "@/types/transaction";
import styled, { useTheme } from "styled-components";

const ChartsWrapper = styled.div`
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: 1fr;
  gap: 24px;
  width: 100%;
  box-sizing: border-box;
`;

function prepareChartDataOptimized(data: ITransaction[], targetPoints = 1000) {
  const parseAmount = (amountStr: string | number) => {
    const n = Number(amountStr) || 0;
    return n / 100;
  };

  const industryMap = new Map<string, { deposit: number; withdraw: number }>();
  const accountMap = new Map<string, { deposit: number; withdraw: number }>();
  const deltas: { ts: number; delta: number }[] = [];

  for (let i = 0; i < data.length; i++) {
    const tx = data[i];
    const amt = parseAmount(tx.amount);


    const ind = (tx.industry ?? "Unknown").toString();
    if (!industryMap.has(ind))
      industryMap.set(ind, { deposit: 0, withdraw: 0 });
    const curInd = industryMap.get(ind)!;
    if (tx.transaction_type === "deposit") curInd.deposit += amt;
    else curInd.withdraw += amt;

    
    const acc = (tx.account ?? "Unknown").toString().trim() || "Unknown";
    if (!accountMap.has(acc)) accountMap.set(acc, { deposit: 0, withdraw: 0 });
    const curAcc = accountMap.get(acc)!;
    if (tx.transaction_type === "deposit") curAcc.deposit += amt;
    else curAcc.withdraw += amt;

   
    const ts =
      typeof tx.date === "number" ? tx.date : new Date(tx.date).getTime();
    const delta = tx.transaction_type === "deposit" ? amt : -amt;
    if (!Number.isFinite(ts)) continue;
    deltas.push({ ts, delta });
  }

 
  const barChartData = Array.from(industryMap.entries()).map(
    ([industry, v]) => ({
      industry,
      deposit: v.deposit,
      withdraw: v.withdraw,
    })
  );

 
  const heatmapData = Array.from(accountMap.entries())
    .map(([account, v]) => ({
      account,
      balance: v.deposit - v.withdraw,
    }))
    .sort((a, b) => b.balance - a.balance); 


  deltas.sort((a, b) => a.ts - b.ts);
  const N = deltas.length;
  const finalLine: { date: string; balance: number }[] = [];

  if (N === 0) return { barChartData, lineChartData: [], heatmapData };

  if (N <= targetPoints) {
    let cumulative = 0;
    for (let i = 0; i < N; i++) {
      cumulative += deltas[i].delta;
      finalLine.push({
        date: new Date(deltas[i].ts).toLocaleDateString("pt-BR"),
        balance: cumulative,
      });
    }
  } else {
    const minTs = deltas[0].ts;
    const maxTs = deltas[N - 1].ts;
    const range = Math.max(1, maxTs - minTs);
    const bucketMs = Math.ceil(range / targetPoints);

    const buckets = new Map<
      number,
      { tsSum: number; deltaSum: number; count: number }
    >();
    for (let i = 0; i < N; i++) {
      const { ts, delta } = deltas[i];
      const idx = Math.floor((ts - minTs) / bucketMs);
      const cur = buckets.get(idx);
      if (!cur) buckets.set(idx, { tsSum: ts, deltaSum: delta, count: 1 });
      else {
        cur.tsSum += ts;
        cur.deltaSum += delta;
        cur.count += 1;
      }
    }

    let cumulative = 0;
    const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
    for (let k = 0; k < keys.length; k++) {
      const b = buckets.get(keys[k])!;
      const avgTs = Math.round(b.tsSum / b.count);
      cumulative += b.deltaSum;
      finalLine.push({
        date: new Date(avgTs).toLocaleDateString("pt-BR"),
        balance: cumulative,
      });
    }
  }

  return { barChartData, lineChartData: finalLine, heatmapData };
}

export const Charts = ({ data }: { data: ITransaction[] }) => {
  const theme = useTheme();

  const { barChartData, lineChartData, heatmapData } = useMemo(() => {
    return prepareChartDataOptimized(data, 1200);
  }, [data]);

  const currencyFormatter = (value: number) =>
    value.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
  return (
    <ChartsWrapper>
      <ResponsiveContainer width="100%" height={450}>
        <ReBarChart
          data={barChartData}
          margin={{ top: 20, right: 60, left: 60, bottom: 5 }}
        >
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis
            dataKey="industry"
            interval={0}
            angle={-20}
            stroke={theme.colors.text}
            textAnchor="end"
            height={70}
            tick={{ fontSize: 12 }}
          />
          <YAxis stroke={theme.colors.text} tickFormatter={currencyFormatter} />
          <Tooltip
            contentStyle={{ backgroundColor: theme.colors.background }}
            formatter={(v) => currencyFormatter(Number(v))}
          />
          <Legend />
          <Bar dataKey="deposit" fill="#4caf50" />
          <Bar dataKey="withdraw" fill="#f44336" />
        </ReBarChart>
      </ResponsiveContainer>

      <ResponsiveContainer width="100%" height={450}>
        <ReLineChart
          data={lineChartData}
          margin={{ top: 20, right: 50, left: 50, bottom: 5 }}
        >
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis
            stroke={theme.colors.text}
            dataKey="date"
            interval={Math.max(0, Math.floor(lineChartData.length / 8))}
          />
          <YAxis stroke={theme.colors.text} tickFormatter={currencyFormatter} />
          <Tooltip
            contentStyle={{ backgroundColor: theme.colors.background }}
            formatter={(v) => currencyFormatter(Number(v))}
          />
          <Line
            type="monotone"
            dataKey="balance"
            stroke={theme.colors.secondary}
            dot={false}
          />
        </ReLineChart>
      </ResponsiveContainer>

      <ResponsiveContainer width="100%" height={1500}>
        <ReBarChart
          layout="vertical"
          data={heatmapData}
          margin={{ top: 20, right: 50, left: 120, bottom: 5 }}
        >
          <XAxis
            type="number"
            stroke={theme.colors.text}
            tickFormatter={currencyFormatter}
          />
          <YAxis
            stroke={theme.colors.text}
            type="category"
            angle={-10}
            tick={{ fontSize: 12 }}
            dataKey="account"
          />
          <Tooltip
            contentStyle={{ backgroundColor: theme.colors.background }}
            formatter={(value) => currencyFormatter(Number(value))}
          />
          <Bar dataKey="balance" fill="#03a9f4" />
        </ReBarChart>
      </ResponsiveContainer>
    </ChartsWrapper>
  );
};
Descrição: componente que prepara os dados e renderiza três gráficos usando recharts: um bar chart por indústria, um line chart do saldo ao longo do tempo e um bar chart vertical (heatmap) com saldo por conta.
Header
typescript
import styled from "styled-components";

const HeaderContainer = styled.div`
  height: 60px;
  width: 100%;
  background-color: ${({ theme }) => theme.colors.surface};
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  color: ${({ theme }) => theme.colors.text};
`;

export const Header = () => (
  <HeaderContainer>
    <div>Dashboard</div>
  </HeaderContainer>
);
Sidebar
typescript
import { useState } from "react";
import styled from "styled-components";
import {
  Home,
  SquareChevronRight,
  SquareChevronLeft,
  LogOut,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Cookies from "js-cookie";

const SidebarContainer = styled.div<{ $collapsed: boolean }>`
  width: ${({ $collapsed }) => ($collapsed ? "60px" : "150px")};
  height: 100vh;
  background-color: ${({ theme }) => theme.colors.primary};
  color: #ffffff;
  display: flex;
  flex-direction: column;
  padding: 20px 10px;
  transition: width 0.3s ease;
  position: sticky;
  top: 0;
`;

const ToggleButton = styled.button<{ $collapsed: boolean }>`
  background: none;
  border: none;
  color: #ffffff;
  font-size: 1.5rem;
  cursor: pointer;
  margin-bottom: 20px;
  align-self: ${({ $collapsed }) => ($collapsed ? "center" : "flex-end")};
`;

const SidebarItem = styled.div<{ $collapsed: boolean }>`
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 15px 0;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 1;
  transition: all 0.2s ease;
  justify-content: ${({ $collapsed }) => ($collapsed ? "center" : "center")};
  span {
    display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")};
  }

  &:hover {
    opacity: 0.8;
  }
`;

export const Sidebar = () => {
  const router = useRouter();
  const [collapsed, setCollapsed] = useState(false);

  return (
    <SidebarContainer $collapsed={collapsed}>
      <ToggleButton
        onClick={() => setCollapsed(!collapsed)}
        $collapsed={collapsed}
      >
        {collapsed ? <SquareChevronRight /> : <SquareChevronLeft />}
      </ToggleButton>
      <SidebarItem $collapsed={collapsed}>
        <Home size={20} /> <span>Home</span>
      </SidebarItem>

      <SidebarItem
        onClick={() => {
          Cookies.remove("token");
          router.push("/");
        }}
        $collapsed={collapsed}
        style={{ marginTop: "auto" }}
      >
        <LogOut size={20} /> <span>Logout</span>
      </SidebarItem>
    </SidebarContainer>
  );
};
Descrição: sidebar colapsável com toggle e botão de logout que remove cookie token.

Componente principal do Dashboard

Arquivo: src/app/componets/dashboprd/dasbord/index.
typescript
"use client";

import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
import { Card } from "../Card";
import { ITransaction } from "@/types/transaction";
import { useEffect, useState } from "react";
import {
  ContentWrapper,
  DashboardContainer,
  MainContent,
  Spinner,
} from "./styled";
import { TransactionsTable } from "@/ui/Table";
import { Charts } from "../Charts";
import { parseAmount } from "@/utils/parseAmount";
import { formatCurrency } from "@/utils/formatCurrency";

interface IDashboardCards {
  revenue: number;
  expenses: number;
  pendingTransactions: number;
  totalBalance: number;
}

export default function DashboardPage({ data }: { data: ITransaction[] }) {
  const [dashboardCards, setDashboardCards] = useState<IDashboardCards>({
    revenue: 0,
    expenses: 0,
    pendingTransactions: 0,
    totalBalance: 0,
  });

  const [loadingCard, setLoadingCard] = useState(true);

  function calculateDashboardCards(data: ITransaction[]): IDashboardCards {
    const now = Date.now();

    const revenue = data
      .filter((tx) => tx.transaction_type === "deposit")
      .reduce((sum, tx) => sum + parseAmount(tx.amount), 0);

    const expenses = data
      .filter((tx) => tx.transaction_type === "withdraw")
      .reduce((sum, tx) => sum + parseAmount(tx.amount), 0);

    const pendingTransactions = data.filter(
      (tx) => Number(tx.date) > now
    ).length;

    const totalBalance = revenue - expenses;

    return {
      revenue,
      expenses,
      pendingTransactions,
      totalBalance,
    };
  }

  useEffect(() => {
    const cards = calculateDashboardCards(data);
    setLoadingCard(false);
    setDashboardCards(cards);
  }, [data]);

  return (
    <DashboardContainer>
      <Sidebar />
      <MainContent>
        <Header />
        <ContentWrapper>
          <Card>
            Revenue:{" "}
            {loadingCard ? <Spinner /> : formatCurrency(dashboardCards.revenue)}
          </Card>
          <Card>
            Expenses:{" "}
            {loadingCard ? (
              <Spinner />
            ) : (
              formatCurrency(dashboardCards.expenses)
            )}
          </Card>
          <Card>
            Pending:
            {loadingCard ? (
              <Spinner />
            ) : (
              formatCurrency(dashboardCards.pendingTransactions)
            )}
          </Card>
          <Card>
            Total Balance:
            {loadingCard ? (
              <Spinner />
            ) : (
              formatCurrency(dashboardCards.totalBalance)
            )}
          </Card>

          <TransactionsTable data={data} itemsPerPage={10} />
          <Charts data={data} />
        </ContentWrapper>
      </MainContent>
    </DashboardContainer>
  );
}
Descrição: componente cliente que monta a interface do dashboard: calcula os KPIs (revenue, expenses, pendingTransactions, totalBalance), exibe Cards, a tabela de transações e os gráficos.
Styled do Dashboard
typescript
import styled, { keyframes } from "styled-components";

export const DashboardContainer = styled.div`
  display: flex;
`;

export const MainContent = styled.div`
  flex: 1;
  background-color: ${({ theme }) => theme.colors.background};
  min-height: 100vh;
  display: flex;
  flex-direction: column;
`;

export const ContentWrapper = styled.div`
  padding: 20px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  overflow-y: auto;
  max-height: calc(100vh - 60px);
  gap: 20px;
`;

export const spin = keyframes`
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
`;

export const Spinner = styled.div`
  border: 3px solid #f3f3f3;
  border-top: 3px solid ${({ theme }) => theme.colors.primary};
  border-radius: 50%;
  width: 20px;
  height: 20px;
  animation: ${spin} 1s linear infinite;
  margin: 10px auto;
`;

export const TransactionsTableWrapper = styled.div`
  grid-column: 1 / -1;
  width: 100%;
  margin-top: 0px;
`;
Descrição: estilos do container principal do dashboard, area de conteúdo, spinner e wrapper da tabela.

Capítulo 7 — Documentação: Filtros

Resumo do que foi feito

  • Instalação de dependências: react-date-range e dayjs.
  • Criação do filtro de data (DateRange / DateRangePicker usando react-date-range).
  • Implementação de um componente Tooltip (popover) para exibir o seletor de datas.
  • Componente Button genérico adicionado/atualizado.
  • Hook useWindowSize criado para ajuste mobile/desktop.
  • Componentes de filtro para account, industry, state e type (selects que atualizam searchParams).
  • Componente Select reutilizável criado para os selects dos filtros.
  • utils/filterDashboard.ts implementado para aplicar os filtros no servidor/SSR.
  • Alteração da rota src/app/dashboard/page.tsx para aplicar filterTransactions e gerar filterOptions.
  • Atualização do componente Dashboard para receber (format, filterOptions) e exibir dados filtrados.
  • Atualização do Header para integrar o seletor de datas, selects de filtro e exibir tags dos filtros aplicados.
O filtro de data foi finalizado e os filtros de account, industry, state e type foram implementados e integrados ao fluxo de query string.

Estrutura (arquivos mencionados)

  • src/ui/tooltip/index.tsx — componente Tooltip / popover
  • src/ui/tooltip/styled.ts — estilos do Tooltip
  • src/ui/Button/index.tsx — componente Button
  • src/ui/Button/styled.ts — estilos do Button
  • src/hooks/useWindowSize.ts — hook para tamanho da janela
  • src/componentes/filter/date/index.tsx — seletor de datas
  • src/componentesfilter/date/styled.ts — estilos do seletor de datas
  • _src/componentes/header.ts_x — header antigo
  • src/componentes/header.tsx — header atualizado que integra filtros
  • src/utils/filterDashboard.ts — função filterTransactions
  • src/app/dashboard/page.tsx — rota do dashboard que aplica filtros
  • src/componentes/ui/Select/index.tsx — componente Select
  • src/compnents/ui/Select/stelyd.ts — estilos do Select
  • src/componentes/filter/account/index.tsx — account filter
  • src/componentes/filter/state/index.tsx — state filter
  • src/componentes/filter/type/index.tsx — type filter
  • src/componentes/filter/industry/index.tsx — industry filter
  • src/componentes/Dashboard/index.tsx — componente Dashboard atualizado
  • src/componentes/header.tsx — versão client do header com filtros

Detalhes por tópico

Instalação de dependências

Instalei react-date-range para o seletor de datas e dayjs com plugins (_weekday, isBetween, isSameOrAfte_r) para manipulação e comparação de datas.
bash
npm i react-date-range dayjs

Tooltip (Popover)

Arquivo: src/ui/tooltip/index.tsx e src/ui/tooltip/styled.ts
Criei um componente Tooltip que abre um bubble ao passar o mouse/focar e fecha com pequeno delay. Ele recebe content (o conteúdo do popover) e children (o elemento que ativa o popover).
/index.tsx
typescript
import React, { ReactNode, useRef, useState, useEffect } from "react";
import { Bubble, WrapperTooltip } from "./styled";

type Props = {
  content: ReactNode;
  children: ReactNode;
  closeDelay?: number;
};

export const Tooltip: React.FC<Props> = ({
  content,
  children,
  closeDelay = 150,
}) => {
  const [visible, setVisible] = useState(false);
  const ref = useRef<HTMLDivElement | null>(null);
  const timer = useRef<number | null>(null);

  useEffect(() => {
    return () => {
      if (timer.current) window.clearTimeout(timer.current);
    };
  }, []);

  const open = () => {
    if (timer.current) {
      window.clearTimeout(timer.current);
      timer.current = null;
    }
    setVisible(true);
  };

  const close = (e: React.MouseEvent | React.FocusEvent) => {
    const related = e.relatedTarget as Node | null;
    if (related && ref.current && ref.current.contains(related)) return;
    timer.current = window.setTimeout(() => {
      setVisible(false);
      timer.current = null;
    }, closeDelay);
  };

  return (
    <WrapperTooltip
      ref={ref}
      onMouseEnter={open}
      onMouseLeave={close}
      onFocus={open}
      onBlur={close}
    >
      {children}
      <Bubble $visible={visible} role="tooltip" aria-hidden={!visible}>
        {content}
      </Bubble>
    </WrapperTooltip>
  );
};
/styled.tsx
javascript
import styled from "styled-components";

export const WrapperTooltip = styled.div`
  position: relative;
  display: inline-block;
`;

export const Bubble = styled.div<{ $visible: boolean }>`
  visibility: ${({ $visible }) => ($visible ? "visible" : "hidden")};
  opacity: ${({ $visible }) => ($visible ? 1 : 0)};
  transition: opacity 0.15s ease;
  pointer-events: ${({ $visible }) => ($visible ? "auto" : "none")};

  position: absolute;
  top: 120%;
  right: 0;
  white-space: nowrap;
  z-index: 999;

  background: ${({ theme }) => `${theme.colors.border}CC`};
  color: #fff;
  padding: 6px 10px;
  border-radius: 6px;
  font-size: 12px;

  @media (max-width: 600px) {
    left: 50%;
    right: auto;
    transform: translateX(-60%);
  }
`;

Button (UI)

Arquivo: src/ui/Button/index.tsx e src/ui/Button/styled.ts
Atualizei o componente Button reutilizável com variantes (default, primary, secondary) e estilos para hover/active/disabled.
/index.tsx
typescript
import { ButtonHTMLAttributes } from "react";
import { StyledButton, Variant } from "./styled";

type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: Variant;
};

export const Button: React.FC<Props> = ({
  children,
  variant = "primary",
  ...rest
}) => {
  return (
    <StyledButton {...rest} variant={variant}>
      {children}
    </StyledButton>
  );
};
/styled.ts
typescript
import styled from "styled-components";

export type Variant = "default" | "primary" | "secondary";

export const StyledButton = styled.button<{ variant?: Variant }>`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 10px 14px;
  border-radius: 10px;
  border: none;
  cursor: pointer;
  font-weight: 600;
  color: ${({ theme }) => theme.colors.text};

  background: ${({ theme, variant }) =>
    variant === "primary"
      ? theme.colors.primary
      : variant === "secondary"
      ? theme.colors.secondary
      : theme.colors.background};

  box-shadow: ${({ theme, variant }) =>
    variant === "primary"
      ? `0 0 10px ${theme.colors.secondary}`
      : variant === "secondary"
      ? `0 0 10px ${theme.colors.primary}`
      : "none"};

  transition: ${({ variant }) =>
    variant === "default" ? "none" : "transform 0.3s ease"};

  &:hover {
    transform: ${({ variant }) => (variant !== "default" ? "scale(1.05)" : "")};
  }

  &:active {
    transform: ${({ variant }) =>
      variant !== "default" ? "translateY(0)" : ""};
  }

  &:disabled {
    opacity: 0.6;
    cursor: not-allowed;
    transform: none;
  }
`

Seletor de datas

Arquivo: src/componentes/dashboard/filter/date/index.tsx e .../styled.ts
O que fiz: Implementado componente SelectDate (client) que:
  • usa react-date-range (DateRange / DateRangePicker);
  • define ranges estáticos customizados (Hoje, Semana Atual, Mês Atual, Mês Passado, Este Ano, Período Total);
  • converte datas para ISO e atualiza a query string (router.push) com ?start=...&end=... quando o intervalo muda;
  • tem botão de Resetar que limpa os parâmetros da query string.
/index.tsx
typescript
"use client";

import { useEffect, useState } from "react";

import {
  createStaticRanges,
  DateRange,
  DateRangePicker,
} from "react-date-range";

import dayjs from "dayjs";
import weekday from "dayjs/plugin/weekday";
import isBetween from "dayjs/plugin/isBetween";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import { useRouter } from "next/navigation";

dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(isSameOrAfter);

import "react-date-range/dist/styles.css";
import "react-date-range/dist/theme/default.css";
import { Button } from "@/ui/Button";
import { useTheme } from "styled-components";
import { WrapperDate } from "./styled";
import { useWindowSize } from "@/hooks/useWindowSize";

export interface FilterRevenuesExpensesInterface {
  status: { id: number; name: string; value: string }[];
}

export const initialStateFilterRevenuesExpenses: FilterRevenuesExpensesInterface =
  {
    status: [],
  };

const initialStateFilter = {
  filter: {
    startDate: new Date(),
    endDate: new Date(),
    key: "filter",
  },
};

export const SelectDate = () => {
  const router = useRouter();
  const theme = useTheme();
  const { width } = useWindowSize();

  const [dates, setDates] = useState(initialStateFilter);

  const customStaticRanges = createStaticRanges([
    {
      label: "Hoje",
      range: () => ({
        startDate: dayjs().toDate(),
        endDate: dayjs().toDate(),
      }),
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs(), "day") &&
        dayjs(range.endDate).isSame(dayjs(), "day"),
    },
    {
      label: "Semana Atual",
      range: () => ({
        startDate: dayjs().weekday(0).toDate(),
        endDate: dayjs().weekday(6).toDate(),
      }),

      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().weekday(0), "day") &&
        dayjs(range.endDate).isSame(dayjs().weekday(6), "day"),
    },
    {
      label: "Mês Atual",
      range: () => ({
        startDate: dayjs().startOf("month").toDate(),
        endDate: dayjs().endOf("month").toDate(),
      }),
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().startOf("month"), "day") &&
        dayjs(range.endDate).isSame(dayjs().endOf("month"), "day"),
    },
    {
      label: "Mês Passado",
      range: () => {
        const lastMonth = dayjs().subtract(1, "month");
        return {
          startDate: lastMonth.startOf("month").toDate(),
          endDate: lastMonth.endOf("month").toDate(),
        };
      },
      isSelected: (range) => {
        const lastMonth = dayjs().subtract(1, "month");
        return (
          dayjs(range.startDate).isSame(lastMonth.startOf("month"), "day") &&
          dayjs(range.endDate).isSame(lastMonth.endOf("month"), "day")
        );
      },
    },
    {
      label: "Este Ano",
      range: () => ({
        startDate: dayjs().startOf("year").toDate(),
        endDate: dayjs().endOf("year").toDate(),
      }),
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().startOf("year"), "day") &&
        dayjs(range.endDate).isSame(dayjs().endOf("year"), "day"),
    },
    {
      label: "Periodo Total",
      range: () => {
        return {
          startDate: dayjs().toDate(),
          endDate: dayjs().toDate(),
        };
      },
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().startOf("year"), "day") &&
        dayjs(range.endDate).isSame(dayjs().endOf("year"), "day"),
    },
  ]);

  const onChange = (newDate: any) => {
    if (newDate["filter"]) {
      setDates({ filter: newDate["filter"] });
      const startDate = dayjs(newDate["filter"].startDate).toISOString();
      const endDate = dayjs(newDate["filter"].endDate).toISOString();
      router.push(`?start=${startDate}&end=${endDate}`);
    }
  };

  const mobileDesign = width < 800;

  return (
    <WrapperDate>
      {mobileDesign ? (
        <DateRange
          editableDateInputs={true}
          moveRangeOnFirstSelection={false}
          onChange={(item) => onChange(item)}
          ranges={[dates.filter]}
        />
      ) : (
        <DateRangePicker
          rangeColors={[theme.colors.primary]}
          onChange={(item) => onChange(item)}
          moveRangeOnFirstSelection={false}
          ranges={[dates.filter]}
          direction="horizontal"
          staticRanges={customStaticRanges}
        />
      )}
      <Button
        variant="default"
        onClick={() => {
          setDates(initialStateFilter);
          router.push(`?`);
        }}
        style={{ margin: "10px  0" }}
      >
        Resetar
      </Button>
    </WrapperDate>
  );
};
/styled.ts
javascript
import styled from "styled-components";

export const WrapperDate = styled.div`
  display: flex;
  flex-direction: column;
  .rdrDefinedRangesWrapper,
  .rdrStaticRange,
  .rdrMonth,
  .rdrMonthAndYearWrapper,
  .rdrDateDisplayWrapper,
  .rdrStaticRangeLabel {
    background: ${({ theme }) => theme.colors.background};
    color: ${({ theme }) => theme.colors.text};
  }
  .rdrDayNumber span {
    color: ${({ theme }) => theme.colors.text};
  }
  .rdrMonthAndYearPickers select {
    color: ${({ theme }) => theme.colors.text};
  }
  .rdrMonth {
    width: 100%;
  }
  @media (max-width: 600px) {
    width: 80vw;
  }
`;

Header do Dashboard (integração dos filtros)

Arquivos: src/componentes/dashboard/header.ts e src/componentes/dashboard/header.tsx
O que fiz: Atualizei o header para:
  • incluir o botão que abre o Tooltip com o seletor de datas;
  • exibir um rótulo descritivo do período selecionado (ex.: Hoje, Mês Atual, Semana Atual, Período Total ou DD/MM - DD/MM);
  • inserir os selects de filtro (Account, Industry, State, Type);
  • exibir tags (badges) dos filtros aplicados (com botão para remover cada filtro atualizando a query string).
typescript
"use client";

import styled from "styled-components";
import { SelectDate } from "./filter/date";
import { Tooltip } from "@/ui/Tooltip";
import { useEffect, useState, useCallback } from "react";
import dayjs from "dayjs";
import { Button } from "@/ui/Button";
import { AccountFilter } from "./filter/account";
import { IndustryFilter } from "./filter/industry";
import { StateFilter } from "./filter/state";
import { useSearchParams, useRouter } from "next/navigation";
import { TypeFilter } from "./filter/type";

const HeaderContainer = styled.header`
  width: 100%;
  background-color: ${({ theme }) => theme.colors.surface};
  color: ${({ theme }) => theme.colors.text};
  padding: 12px 20px;
  padding-right: 70px;
  box-sizing: border-box;
  border-bottom: 1px solid ${({ theme }) => theme.colors.border};
`;

const TopRow = styled.div`
  display: flex;
  gap: 16px;
  align-items: center;
  justify-content: space-between;
  width: 100%;

  @media (max-width: 720px) {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }
`;

const Title = styled.h1`
  margin: 0;
  font-size: 18px;
  font-weight: 700;
`;

const Controls = styled.div`
  display: flex;
  gap: 12px;
  align-items: center;
  min-width: 0;

  @media (max-width: 720px) {
    justify-content: space-between;
  }
`;

const TagsRow = styled.div`
  margin-top: 10px;
  display: flex;
  gap: 8px;
  align-items: center;
  width: 100%;
  overflow-x: auto;
  padding-bottom: 6px;
  overflow-x: auto;

  &::-webkit-scrollbar {
    height: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background: ${({ theme }) => theme.colors.border};
    border-radius: 6px;
  }
`;

const TagFilter = styled.div`
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 32px;
  padding: 0 12px;
  border-radius: 999px;
  background: ${({ theme }) => `${theme.colors.primary}22`};
  color: ${({ theme }) => theme.colors.primary};
  font-weight: 600;
  font-size: 13px;
  white-space: nowrap;
  flex-shrink: 0;
`;

const TagType = styled.span`
  display: inline-block;
  padding: 2px 6px;
  border-radius: 6px;
  background: ${({ theme }) => `${theme.colors.primary}33`};
  color: ${({ theme }) => theme.colors.primary};
  font-size: 11px;
  text-transform: uppercase;
  font-weight: 700;
`;

const TagClose = styled.button`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: inherit;
  cursor: pointer;
  padding: 0;
  font-size: 12px;
  line-height: 1;
`;

const ControlGroup = styled.div`
  display: flex;
  gap: 8px;
  align-items: center;
`;

type TagItem = {
  type: "account" | "industry" | "state" | "type";
  value: string;
};

export const Header = ({
  filterOptions,
}: {
  filterOptions: {
    account: string[];
    industry: string[];
    state: string[];
    transaction_type: string[];
  };
}) => {
  const [labelCalendar, setLabelCalendar] = useState("Período Total");

  const searchParams = useSearchParams();
  const router = useRouter();

  const dateIni = searchParams.get("start") ?? "";
  const dateFim = searchParams.get("end") ?? "";

  const accountFilter = searchParams.getAll("account");
  const industryFilter = searchParams.getAll("industry");
  const stateFilter = searchParams.getAll("state");
  const typeFilter = searchParams.getAll("type");

  const combinedTags: TagItem[] = [
    ...accountFilter.map((v) => ({ type: "account" as const, value: v })),
    ...industryFilter.map((v) => ({ type: "industry" as const, value: v })),
    ...stateFilter.map((v) => ({ type: "state" as const, value: v })),
    ...typeFilter.map((v) => ({ type: "type" as const, value: v })),
  ];

  useEffect(() => {
    if (!dateIni && !dateFim) {
      setLabelCalendar("Período Total");
      return;
    }

    const start = dayjs(dateIni);
    const end = dayjs(dateFim);

    const isSameYear =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().startOf("year"), "day") &&
      end.isSame(dayjs().endOf("year"), "day");

    if (isSameYear) {
      setLabelCalendar("Este Ano");
      return;
    }

    const lastMonth = dayjs().subtract(1, "month");
    const isLastMonth =
      start.isValid() &&
      end.isValid() &&
      start.isSame(lastMonth.startOf("month"), "day") &&
      end.isSame(lastMonth.endOf("month"), "day");

    if (isLastMonth) {
      setLabelCalendar("Mês Passado");
      return;
    }

    const isSameMonth =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().startOf("month"), "day") &&
      end.isSame(dayjs().endOf("month"), "day");

    if (isSameMonth) {
      setLabelCalendar("Mês Atual");
      return;
    }

    const isSameWeek =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().weekday(0), "day") &&
      end.isSame(dayjs().weekday(6), "day");

    if (isSameWeek) {
      setLabelCalendar("Semana Atual");
      return;
    }

    const today =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs(), "day") &&
      end.isSame(dayjs(), "day");

    if (today) {
      setLabelCalendar("Hoje");
      return;
    }

    if (start.isValid() && end.isValid()) {
      setLabelCalendar(`${start.format("DD/MM")} - ${end.format("DD/MM")}`);
    } else {
      setLabelCalendar("Período Total");
    }
  }, [dateIni, dateFim]);

  const removeFilter = useCallback(
    (type: TagItem["type"], value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      const existing = params.getAll(type).filter((v) => v !== value);
      params.delete(type);
      existing.forEach((v) => params.append(type, v));
      const qs = params.toString();

      router.push(`?${qs}`);
    },
    [searchParams, router]
  );

  return (
    <HeaderContainer>
      <TopRow>
        <Title>Dashboard</Title>

        <Controls>
          <ControlGroup>
            <Tooltip content={<SelectDate />}>
              <Button variant="default">{labelCalendar}</Button>
            </Tooltip>

            <AccountFilter options={filterOptions.account} />
            <IndustryFilter options={filterOptions.industry} />
            <StateFilter options={filterOptions.state} />
            <TypeFilter options={filterOptions.transaction_type} />
          </ControlGroup>
        </Controls>
      </TopRow>

      {/* Tags */}
      {combinedTags.length > 0 && (
        <TagsRow aria-label="Filtros aplicados">
          {combinedTags.map((tag) => (
            <TagFilter key={`${tag.type}-${tag.value}`}>
              <TagType>{tag.type}</TagType>
              <span>{tag.value}</span>
              <TagClose
                aria-label={`Remover filtro ${tag.value}`}
                onClick={() => removeFilter(tag.type, tag.value)}
              >
                ×
              </TagClose>
            </TagFilter>
          ))}
        </TagsRow>
      )}
    </HeaderContainer>
  );
};

Select (componente reutilizável)

Arquivo: src/componentes/ui/Select/index.tsx e src/compnents/ui/Select/stelyd.ts
O que fiz: Criei componente Select que renderiza options a partir de uma options prop e exibe um ícone de seta. Os Selects foram usados pelos filtros de account, industry, state e type.
/index.ts
javascript
import React from "react";
import { IconWrapper, StyledSelect, WrapperSelect } from "./styled";
import { ArrowDown } from "lucide-react";

export type OptionType = {
  value: string | number;
  label: string;
  disabled?: boolean;
};

type Props = React.SelectHTMLAttributes<HTMLSelectElement> & {
  options: OptionType[];
  placeholder?: string;
  fullWidth?: boolean;
};

export const Select: React.FC<Props> = ({
  options,
  placeholder,
  fullWidth,
  children,
  ...rest
}) => {
  const hasValue = rest.value !== undefined && rest.value !== null;
  const hasDefault =
    rest.defaultValue !== undefined && rest.defaultValue !== null;
  const selectProps: any = { ...rest };
  if (placeholder && !hasValue && !hasDefault) selectProps.defaultValue = "";

  return (
    <WrapperSelect $full={!!fullWidth}>
      <StyledSelect
        {...selectProps}
        aria-label={rest["aria-label"] ?? placeholder}
      >
        {placeholder && (
          <option value="" disabled hidden>
            {placeholder}
          </option>
        )}
        {options
          ? options.map((opt) => (
              <option
                key={String(opt.value)}
                value={opt.value}
                disabled={opt.disabled}
              >
                {opt.label}
              </option>
            ))
          : children}
      </StyledSelect>
      <IconWrapper>
        <ArrowDown />
      </IconWrapper>
    </WrapperSelect>
  );
};
/styled.ts
javascript
import styled from "styled-components";

export const WrapperSelect = styled.div<{ $full?: boolean }>`
  position: relative;
  display: inline-block;
  width: ${({ $full }) => ($full ? "100%" : "auto")};
`;

export const StyledSelect = styled.select<{ $full?: boolean; $open?: boolean }>`
  width: 100%;
  padding: 10px 36px 10px 14px;
  border-radius: 10px;
  border: 1px solid ${({ theme }) => theme.colors.border};
  background-color: ${({ theme }) => theme.colors.surface};
  color: ${({ theme }) => theme.colors.text};
  appearance: none;
  cursor: pointer;
  + span {
    transform: translateY(-50%) rotate(90deg);
  }
  &:hover + span {
    transform: translateY(-50%) rotate(0deg);
  }
`;

export const IconWrapper = styled.span`
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
  color: ${({ theme }) => theme.colors.primary};
  transition: transform 0.3s ease;
`;

Filtros (Account / Industry / State / Type)

Arquivos:
  • src/componentes/dashborad/filter/account/index.tsx
  • src/componentes/dashborad/filter/industry/index.tsx
  • src/componentes/dashborad/filter/state/index.tsx
  • src/componentes/dashborad/filter/type/index.tsx
O que fiz: Para cada filtro criei um componente client que:
  • recebe options: string[];
  • transforma em OptionType[] para o Select;
  • ao selecionar, ajusta a query string (URLSearchParams) adicionando/removendo valores (alguns filtros aceitam múltiplos valores: account, industry, state, enquanto type usa set para um único valor);
  • realiza router.push com a nova query string para atualizar a rota e disparar SSR/filtragem no servidor.
/account/*
javascript
"use client";

import { OptionType, Select } from "@/ui/Select";
import { useRouter, useSearchParams } from "next/navigation";

export const AccountFilter = ({ options }: { options: string[] }) => {
  const router = useRouter();
  const searchParams = useSearchParams();

  const data: OptionType[] = options.map((o) => ({ label: o, value: o }));

  const addFilter = (value: string) => {
    const params = new URLSearchParams(searchParams.toString());

    const existing = params.getAll("account");

    if (existing.includes(value)) {
      const filtered = existing.filter((v) => v !== value);
      params.delete("account");
      filtered.forEach((v) => params.append("account", v));
    } else {
      params.append("account", value);
    }

    router.push(`?${params.toString()}`);
  };

  return (
    <Select
      options={data}
      placeholder="Account"
      onChange={(e) => addFilter(e.target.value)}
    />
  );
};
/industry/*
javascript
"use client";

import { OptionType, Select } from "@/ui/Select";
import { useRouter, useSearchParams } from "next/navigation";

export const IndustryFilter = ({ options }: { options: string[] }) => {
  const router = useRouter();
  const searchParams = useSearchParams();

  const data: OptionType[] = options.map((o) => ({ label: o, value: o }));

  const addFilter = (value: string) => {
    const params = new URLSearchParams(searchParams.toString());

    const existing = params.getAll("industry");

    if (existing.includes(value)) {
      const filtered = existing.filter((v) => v !== value);
      params.delete("industry");
      filtered.forEach((v) => params.append("industry", v));
    } else {
      params.append("industry", value);
    }

    router.push(`?${params.toString()}`);
  };

  return (
    <Select
      placeholder="Industry"
      options={data}
      onChange={(e) => addFilter(e.target.value)}
    />
  );
};
/state/*
javascript
"use client";

import { OptionType, Select } from "@/ui/Select";
import { useRouter, useSearchParams } from "next/navigation";

export const StateFilter = ({ options }: { options: string[] }) => {
  const router = useRouter();
  const searchParams = useSearchParams();

  const data: OptionType[] = options.map((o) => ({ label: o, value: o }));

  const addFilter = (value: string) => {
    const params = new URLSearchParams(searchParams.toString());

    const existing = params.getAll("state");

    if (existing.includes(value)) {
      const filtered = existing.filter((v) => v !== value);
      params.delete("state");
      filtered.forEach((v) => params.append("state", v));
    } else {
      params.append("state", value);
    }

    router.push(`?${params.toString()}`);
  };

  return (
    <Select
      placeholder="State"
      options={data}
      onChange={(e) => addFilter(e.target.value)}
    />
  );
};
/type/*
javascript
"use client";

import { OptionType, Select } from "@/ui/Select";
import { useRouter, useSearchParams } from "next/navigation";

export const TypeFilter = ({ options }: { options: string[] }) => {
  const router = useRouter();
  const searchParams = useSearchParams();

  const data: OptionType[] = options.map((o) => ({ label: o, value: o }));

  const setFilter = (value: string) => {
    const params = new URLSearchParams(searchParams.toString());

    if (value) {
      params.set("type", value);
    } else {
      params.delete("type");
    }

    router.push(`?${params.toString()}`);
  };

  return (
    <Select
      placeholder="Type"
      options={data}
      value={searchParams.get("type") ?? ""}
      onChange={(e) => setFilter(e.target.value)}
    />
  );
};

Util filterTransactions

Arquivo: src/utils/filterDashboard.ts
O que fiz: Implementei a função filterTransactions que recebe:
  • data: ITransaction[] e parâmetros opcionais (startDate, endDate, accounts, industries, states, transactionTypes).
Comportamento:
  • converte datas de entrada para milissegundos (aceita 10 ou 13 dígitos, Date ou string);
  • define startMs e endMs (usando min/max do dataset quando params ausentes);
  • normaliza filtros de texto (lowercase e trim) e checa matches;
  • filtra o array e ordena por data desc.
javascript
import { ITransaction } from "@/types/transaction";
import dayjs from "dayjs";

export function filterTransactions({
  data,
  startDate,
  endDate,
  accounts,
  industries,
  states,
  transactionTypes,
}: {
  data: ITransaction[];
  startDate?: string | number | Date;
  endDate?: string | number | Date;
  accounts?: string | string[];
  industries?: string | string[];
  states?: string | string[];
  transactionTypes?: string | string[];
}): ITransaction[] {
  if (!data || data.length === 0) return [];

  const toMs = (v?: string | number | Date): number | undefined => {
    if (v === undefined || v === null || v === "") return undefined;
    if (typeof v === "number" && Number.isFinite(v)) return v;
    const s = String(v).trim();
    if (/^\d{10}$/.test(s)) return Number(s) * 1000;
    if (/^\d{13}$/.test(s)) return Number(s);
    const d = dayjs(s);
    return d.isValid() ? d.valueOf() : undefined;
  };

  const itemTs = data
    .map((d) => toMs(d.date))
    .filter((t): t is number => typeof t === "number" && !Number.isNaN(t));

  if (itemTs.length === 0) return [];

  const minTs = Math.min(...itemTs);
  const maxTs = Math.max(...itemTs);

  const startMs = toMs(startDate) ?? dayjs(minTs).startOf("day").valueOf();
  const endMs = toMs(endDate) ?? dayjs(maxTs).endOf("day").valueOf();

  const normalize = (v?: string | string[] | undefined): string[] | null => {
    if (v === undefined || v === null) return null;
    const arr = Array.isArray(v) ? v : [v];
    const cleaned = arr
      .map((x) => String(x).trim().toLowerCase())
      .filter(Boolean);
    return cleaned.length ? cleaned : null;
  };

  const accs = normalize(accounts);
  const inds = normalize(industries);
  const sts = normalize(states);
  const types = normalize(transactionTypes);

  const matches = (list: string[] | null, value?: unknown) => {
    if (!list) return true;
    return list.includes(
      String(value ?? "")
        .trim()
        .toLowerCase()
    );
  };

  const result = data.filter((item) => {
    const ts = toMs(item.date);
    if (ts === undefined) return false;
    if (ts < startMs || ts > endMs) return false;
    if (!matches(accs, item.account)) return false;
    if (!matches(inds, item.industry)) return false;
    if (!matches(sts, item.state)) return false;
    if (!matches(types, item.transaction_type)) return false;
    return true;
  });

  return result.sort((a, b) => {
    const ta = toMs(a.date) ?? 0;
    const tb = toMs(b.date) ?? 0;
    return tb - ta;
  });
}

Rota do Dashboard (aplicando filtros)

Arquivo: src/app/dashboard/page.tsx
O que fiz: Na rota do dashboard:
  • li searchParams (start, end, account, industry, state, type);
  • carreguei transactions e apliquei filterTransactions
  • gerei filterOptions com os valores únicos (account, industry, state, transaction_type) para popular os selects;
  • retornei <DashboardPage format={result} filterOptions={filterOptions} />.
Espaço reservado para o código da rota
javascript
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import DashboardPage from "@/components/dashboard/Dashboard";
import transactions from "@/db/transactions.json";
import { ITransaction } from "@/types/transaction";
import { AuthValidation } from "@/utils/auth";
import { filterTransactions } from "@/utils/filterDashboard";

export default async function Dashboard({
  searchParams,
}: {
  searchParams: Record<string, any> | Promise<Record<string, any>>;
}) {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  const params = await Promise.resolve(searchParams);

  const auth = AuthValidation(token);
  if (!auth) {
    redirect("/");
  }

  const data: ITransaction[] = transactions as ITransaction[];

  const result = filterTransactions({
    data: data,
    startDate: params.start,
    endDate: params.end,
    accounts: params.account,
    industries: params.industry,
    states: params.state,
    transactionTypes: params.type,
  });

  function buildFilterOptions(
    data: {
      account: string;
      industry: string;
      state: string;
      transaction_type: string;
    }[]
  ) {
    const accounts = new Set<string>();
    const industries = new Set<string>();
    const states = new Set<string>();
    const transaction_type = new Set<string>();

    data.forEach((d) => {
      if (d.account) accounts.add(d.account);
      if (d.industry) industries.add(d.industry);
      if (d.state) states.add(d.state);
      if (d.transaction_type) transaction_type.add(d.transaction_type);
    });

    return {
      account: Array.from(accounts),
      industry: Array.from(industries),
      state: Array.from(states),
      transaction_type: Array.from(transaction_type),
    };
  }

  const filterOptions = buildFilterOptions(data);

  return <DashboardPage format={result} filterOptions={filterOptions} />;
}

Dashboard — integração final

Arquivo: src/componentes/Dashboard/index.tsx
O que fiz: Atualizei o componente principal do Dashboard para:
  • receber filterOptions e format (dados já filtrados pelo servidor);
  • calcular KPIs (revenue, expenses, pendingTransactions, totalBalance) a partir do dataset filtrado;
  • exibir Header (com filterOptions), Cards, TransactionsTable e Charts com os dados filtrados.
/Dashboard/index.tsx
javascript
"use client";

import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
import { Card } from "../Card";
import { ITransaction } from "@/types/transaction";
import { useEffect, useState } from "react";
import {
  ContentWrapper,
  DashboardContainer,
  MainContent,
  Spinner,
} from "./styled";
import { TransactionsTable } from "@/ui/Table";
import { Charts } from "../Charts";
import { parseAmount } from "@/utils/parseAmount";
import { formatCurrency } from "@/utils/formatCurrency";
interface IDashboardCards {
  revenue: number;
  expenses: number;
  pendingTransactions: number;
  totalBalance: number;
}

interface IDashboarPage {
  filterOptions: {
    account: string[];
    industry: string[];
    state: string[];
    transaction_type: string[];
  };
  format: ITransaction[];
}

export default function DashboardPage({
  filterOptions,
  format,
}: IDashboarPage) {
  const [dashboardCards, setDashboardCards] = useState<IDashboardCards>({
    revenue: 0,
    expenses: 0,
    pendingTransactions: 0,
    totalBalance: 0,
  });

  const [loadingCard, setLoadingCard] = useState(true);

  const [dataTable, setDataTable] = useState<ITransaction[]>(format);

  function calculateDashboardCards(data: ITransaction[]): IDashboardCards {
    const now = Date.now();

    const revenue = data
      .filter((tx) => tx.transaction_type === "deposit")
      .reduce((sum, tx) => sum + parseAmount(tx.amount), 0);

    const expenses = data
      .filter((tx) => tx.transaction_type === "withdraw")
      .reduce((sum, tx) => sum + parseAmount(tx.amount), 0);

    const pendingTransactions = data.filter(
      (tx) => Number(tx.date) > now
    ).length;

    const totalBalance = revenue - expenses;

    return {
      revenue,
      expenses,
      pendingTransactions,
      totalBalance,
    };
  }

  useEffect(() => {
    const cards = calculateDashboardCards(format);
    setLoadingCard(false);
    setDashboardCards(cards);
    setDataTable(format);
  }, [format]);

  return (
    <DashboardContainer>
      <Sidebar />
      <MainContent>
        <Header filterOptions={filterOptions} />
        <ContentWrapper>
          <Card>
            Revenue:{" "}
            {loadingCard ? <Spinner /> : formatCurrency(dashboardCards.revenue)}
          </Card>
          <Card>
            Expenses:{" "}
            {loadingCard ? (
              <Spinner />
            ) : (
              formatCurrency(dashboardCards.expenses)
            )}
          </Card>
          <Card>
            Pending:
            {loadingCard ? (
              <Spinner />
            ) : (
              formatCurrency(dashboardCards.pendingTransactions)
            )}
          </Card>
          <Card>
            Total Balance:
            {loadingCard ? (
              <Spinner />
            ) : (
              formatCurrency(dashboardCards.totalBalance)
            )}
          </Card>

          <TransactionsTable data={dataTable} itemsPerPage={10} />
          {dataTable.length > 0 && <Charts data={dataTable} />}
        </ContentWrapper>
      </MainContent>
    </DashboardContainer>
  );
}
src/componentes/Header.tsx
javascript
"use client";

import styled from "styled-components";
import { SelectDate } from "./filter/date";
import { Tooltip } from "@/ui/Tooltip";
import { useEffect, useState, useCallback } from "react";
import dayjs from "dayjs";
import { Button } from "@/ui/Button";
import { AccountFilter } from "./filter/account";
import { IndustryFilter } from "./filter/industry";
import { StateFilter } from "./filter/state";
import { useSearchParams, useRouter } from "next/navigation";
import { TypeFilter } from "./filter/type";

const HeaderContainer = styled.header`
  width: 100%;
  background-color: ${({ theme }) => theme.colors.surface};
  color: ${({ theme }) => theme.colors.text};
  padding: 12px 20px;
  padding-right: 70px;
  box-sizing: border-box;
  border-bottom: 1px solid ${({ theme }) => theme.colors.border};
`;

const TopRow = styled.div`
  display: flex;
  gap: 16px;
  align-items: center;
  justify-content: space-between;
  width: 100%;

  @media (max-width: 720px) {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }
`;

const Title = styled.h1`
  margin: 0;
  font-size: 18px;
  font-weight: 700;
`;

const Controls = styled.div`
  display: flex;
  gap: 12px;
  align-items: center;
  min-width: 0;

  @media (max-width: 720px) {
    justify-content: space-between;
  }
`;

const TagsRow = styled.div`
  margin-top: 10px;
  display: flex;
  gap: 8px;
  align-items: center;
  width: 100%;
  overflow-x: auto;
  padding-bottom: 6px;
  overflow-x: auto;

  &::-webkit-scrollbar {
    height: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background: ${({ theme }) => theme.colors.border};
    border-radius: 6px;
  }
`;

const TagFilter = styled.div`
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 32px;
  padding: 0 12px;
  border-radius: 999px;
  background: ${({ theme }) => `${theme.colors.primary}22`};
  color: ${({ theme }) => theme.colors.primary};
  font-weight: 600;
  font-size: 13px;
  white-space: nowrap;
  flex-shrink: 0;
`;

const TagType = styled.span`
  display: inline-block;
  padding: 2px 6px;
  border-radius: 6px;
  background: ${({ theme }) => `${theme.colors.primary}33`};
  color: ${({ theme }) => theme.colors.primary};
  font-size: 11px;
  text-transform: uppercase;
  font-weight: 700;
`;

const TagClose = styled.button`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: inherit;
  cursor: pointer;
  padding: 0;
  font-size: 12px;
  line-height: 1;
`;

const ControlGroup = styled.div`
  display: flex;
  gap: 8px;
  align-items: center;
`;

type TagItem = {
  type: "account" | "industry" | "state" | "type";
  value: string;
};

export const Header = ({
  filterOptions,
}: {
  filterOptions: {
    account: string[];
    industry: string[];
    state: string[];
    transaction_type: string[];
  };
}) => {
  const [labelCalendar, setLabelCalendar] = useState("Período Total");

  const searchParams = useSearchParams();
  const router = useRouter();

  const dateIni = searchParams.get("start") ?? "";
  const dateFim = searchParams.get("end") ?? "";

  const accountFilter = searchParams.getAll("account");
  const industryFilter = searchParams.getAll("industry");
  const stateFilter = searchParams.getAll("state");
  const typeFilter = searchParams.getAll("type");

  const combinedTags: TagItem[] = [
    ...accountFilter.map((v) => ({ type: "account" as const, value: v })),
    ...industryFilter.map((v) => ({ type: "industry" as const, value: v })),
    ...stateFilter.map((v) => ({ type: "state" as const, value: v })),
    ...typeFilter.map((v) => ({ type: "type" as const, value: v })),
  ];

  useEffect(() => {
    if (!dateIni && !dateFim) {
      setLabelCalendar("Período Total");
      return;
    }

    const start = dayjs(dateIni);
    const end = dayjs(dateFim);

    const isSameYear =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().startOf("year"), "day") &&
      end.isSame(dayjs().endOf("year"), "day");

    if (isSameYear) {
      setLabelCalendar("Este Ano");
      return;
    }

    const lastMonth = dayjs().subtract(1, "month");
    const isLastMonth =
      start.isValid() &&
      end.isValid() &&
      start.isSame(lastMonth.startOf("month"), "day") &&
      end.isSame(lastMonth.endOf("month"), "day");

    if (isLastMonth) {
      setLabelCalendar("Mês Passado");
      return;
    }

    const isSameMonth =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().startOf("month"), "day") &&
      end.isSame(dayjs().endOf("month"), "day");

    if (isSameMonth) {
      setLabelCalendar("Mês Atual");
      return;
    }

    const isSameWeek =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().weekday(0), "day") &&
      end.isSame(dayjs().weekday(6), "day");

    if (isSameWeek) {
      setLabelCalendar("Semana Atual");
      return;
    }

    const today =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs(), "day") &&
      end.isSame(dayjs(), "day");

    if (today) {
      setLabelCalendar("Hoje");
      return;
    }

    if (start.isValid() && end.isValid()) {
      setLabelCalendar(`${start.format("DD/MM")} - ${end.format("DD/MM")}`);
    } else {
      setLabelCalendar("Período Total");
    }
  }, [dateIni, dateFim]);

  const removeFilter = useCallback(
    (type: TagItem["type"], value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      const existing = params.getAll(type).filter((v) => v !== value);
      params.delete(type);
      existing.forEach((v) => params.append(type, v));
      const qs = params.toString();

      router.push(`?${qs}`);
    },
    [searchParams, router]
  );

  return (
    <HeaderContainer>
      <TopRow>
        <Title>Dashboard</Title>

        <Controls>
          <ControlGroup>
            <Tooltip content={<SelectDate />}>
              <Button variant="default">{labelCalendar}</Button>
            </Tooltip>

            <AccountFilter options={filterOptions.account} />
            <IndustryFilter options={filterOptions.industry} />
            <StateFilter options={filterOptions.state} />
            <TypeFilter options={filterOptions.transaction_type} />
          </ControlGroup>
        </Controls>
      </TopRow>

      {/* Tags */}
      {combinedTags.length > 0 && (
        <TagsRow aria-label="Filtros aplicados">
          {combinedTags.map((tag) => (
            <TagFilter key={`${tag.type}-${tag.value}`}>
              <TagType>{tag.type}</TagType>
              <span>{tag.value}</span>
              <TagClose
                aria-label={`Remover filtro ${tag.value}`}
                onClick={() => removeFilter(tag.type, tag.value)}
              >
                ×
              </TagClose>
            </TagFilter>
          ))}
        </TagsRow>
      )}
    </HeaderContainer>
  );
};

Capítulo 8 — Responsividade

Explicações detalhadas por arquivo (didático)

src/ui/Tooltip/styled.ts

O que foi alterado:
  • O WrapperTooltip agora ganha width: 100% em telas menores (@media (max-width: 1180px)), para que o elemento ativador possa ocupar a largura disponível.
  • O Bubble (popover) muda sua posição em telas pequenas: em vez de ficar right: 0, ele centraliza horizontalmente com left: 50% + transform: translateX(-50%) para não vazar da tela.
javascript
import styled from "styled-components";

export const WrapperTooltip = styled.div`
  position: relative;
  display: inline-block;
  @media (max-width: 1180px) {
    width: 100%;
  }
`;

export const Bubble = styled.div<{ $visible: boolean }>`
  visibility: ${({ $visible }) => ($visible ? "visible" : "hidden")};
  opacity: ${({ $visible }) => ($visible ? 1 : 0)};
  transition: opacity 0.15s ease;
  pointer-events: ${({ $visible }) => ($visible ? "auto" : "none")};

  position: absolute;
  top: 120%;
  right: 0;
  white-space: nowrap;
  z-index: 999;

  background: ${({ theme }) => `${theme.colors.border}CC`};
  color: #fff;
  padding: 6px 10px;
  border-radius: 6px;
  font-size: 12px;

  @media (max-width: 600px) {
    left: 50%;
    right: auto;
    transform: translateX(-50%);
    position: absolute;
  }
`;

code
src/ui/Table/*
(index + styled)

O que foi alterado:
  • Introdução de
    code
    TableContainer
    e
    code
    TableWrapper
    com
    code
    overflow-x: auto
    para permitir scroll horizontal em telas pequenas.
  • code
    Table
    tem
    code
    min-width: 500px
    (ou outro valor) para preservar colunas legíveis; o wrapper permite scroll em dispositivos móveis.
  • Paginação: botão de pular 10 páginas (
    code
    <<
    e
    code
    >>
    ) e
    code
    PageButton
    com padding reduzido em telas < 480px.
src/ui/Table/index.tsx
javascript
import { useState } from "react";
import {
  PageButton,
  PaginationInner,
  PaginationWrapper,
  Table,
  TableContainer,
  TableWrapper,
  Td,
  Th,
} from "./styled";
import { ITransaction } from "@/types/transaction";

interface TransactionsTableProps {
  data: ITransaction[];
  itemsPerPage?: number;
}

export const TransactionsTable = ({
  data,
  itemsPerPage = 10,
}: TransactionsTableProps) => {
  const [currentPage, setCurrentPage] = useState(1);

  const totalPages = Math.ceil(data.length / itemsPerPage);
  const startIndex = (currentPage - 1) * itemsPerPage;
  const currentItems = data.slice(startIndex, startIndex + itemsPerPage);

  function getPaginationPages(current: number, total: number, maxVisible = 5) {
    const pages = [];

    if (total <= maxVisible) {
      for (let i = 1; i <= total; i++) pages.push(i);
    } else {
      pages.push(1);

      const start = Math.max(2, current - 1);
      const end = Math.min(total - 1, current + 1);

      if (start > 2) pages.push("...");
      for (let i = start; i <= end; i++) pages.push(i);
      if (end < total - 1) pages.push("...");

      pages.push(total);
    }

    return pages;
  }

  const pages = getPaginationPages(currentPage, totalPages);

  return (
    <TableContainer>
      <TableWrapper>
        <Table>
          <thead>
            <tr>
              <Th>Date</Th>
              <Th>Account</Th>
              <Th>Industry</Th>
              <Th>Type</Th>
              <Th>Amount</Th>
            </tr>
          </thead>
          <tbody>
            {currentItems.map((tx, index) => (
              <tr key={index}>
                <Td>{new Date(Number(tx.date)).toLocaleDateString()}</Td>
                <Td>{tx.account}</Td>
                <Td>{tx.industry}</Td>
                <Td
                  style={{
                    color: tx.transaction_type === "deposit" ? "green" : "red",
                  }}
                >
                  {tx.transaction_type}
                </Td>
                <Td>
                  {Number(tx.amount).toLocaleString("en-US", {
                    style: "currency",
                    currency: tx.currency.toUpperCase(),
                  })}
                </Td>
              </tr>
            ))}
          </tbody>
        </Table>
      </TableWrapper>

      <PaginationWrapper>
        <PaginationInner>
          <PageButton
            onClick={() => setCurrentPage(Math.max(currentPage - 10, 1))}
            disabled={currentPage === 1}
          >
            &lt; &lt;
          </PageButton>
          <PageButton
            onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))}
            disabled={currentPage === 1}
          >
            &lt;
          </PageButton>
          {pages.map((p) => (
            <PageButton key={p} $active={p === currentPage}>
              {p}
            </PageButton>
          ))}
          <PageButton
            onClick={() =>
              setCurrentPage(Math.min(currentPage + 1, totalPages))
            }
            disabled={currentPage === totalPages}
          >
            &gt;
          </PageButton>
          <PageButton
            onClick={() =>
              setCurrentPage(Math.min(currentPage + 10, totalPages))
            }
            disabled={currentPage === totalPages}
          >
            &gt;&gt;
          </PageButton>
        </PaginationInner>
      </PaginationWrapper>
    </TableContainer>
  );
};
src/ui/Table/styled.ts
javascript
import styled from "styled-components";

export const TableContainer = styled.div`
  width: 100%;
  margin-top: 40px;
  border-radius: 10px;
  overflow: hidden;
  border: 1px solid #ddd;
  grid-column: 1 / -1;
  width: 100%;
  margin-top: 0px;
  overflow-x: auto;
`;

export const TableWrapper = styled.div`
  overflow-x: auto;
  max-width: 100%;
  padding-bottom: 8px;
`;

export const Table = styled.table`
  width: 100%;
  min-width: 500px;
  border-collapse: collapse;
`;

export const Th = styled.th`
  text-align: left;
  padding: 12px;
  background-color: #6200ee;
  color: white;
  border-bottom: 1px solid #ddd;
`;

export const Td = styled.td`
  padding: 12px;
  border-bottom: 1px solid #ddd;
  background-color: ${({ theme }) => theme.colors.surface};
`;

export const PaginationWrapper = styled.div`
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  margin: 12px 0;
  padding-bottom: 12px;

  width: 100%;
  display: flex;
  justify-content: center;
`;

export const PaginationInner = styled.div`
  display: inline-flex;
  gap: 8px;
  width: max-content;
`;

export const PageButton = styled.button<{ $active?: boolean }>`
  flex: 0 0 auto;
  padding: 6px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  background-color: ${({ $active }) => ($active ? "#6200ee" : "white")};
  color: ${({ $active }) => ($active ? "white" : "#333")};
  cursor: pointer;
  margin: 0 4px;

  &:hover {
    background-color: ${({ $active }) => ($active ? "#6200ee" : "#f0f0f0")};
  }

  @media (max-width: 480px) {
    padding: 4px 8px;
    font-size: 12px;
  }
`;

src/ui/Button/styled.ts

O que foi alterado:
  • StyledButton agora tem width: 100%. Isso garante que botões em controles (como no header / filtros) ocupem todo o espaço disponível em componentes empilhados no mobile, resultando em áreas de toque maiores.
javascript
import styled from "styled-components";

export type Variant = "default" | "primary" | "secondary";

export const StyledButton = styled.button<{ variant?: Variant }>`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 10px 14px;
  border-radius: 10px;
  border: none;
  cursor: pointer;
  font-weight: 600;
  color: ${({ theme }) => theme.colors.text};
  width: 100%;
  background: ${({ theme, variant }) =>
    variant === "primary"
      ? theme.colors.primary
      : variant === "secondary"
      ? theme.colors.secondary
      : theme.colors.background};

  box-shadow: ${({ theme, variant }) =>
    variant === "primary"
      ? `0 0 10px ${theme.colors.secondary}`
      : variant === "secondary"
      ? `0 0 10px ${theme.colors.primary}`
      : "none"};

  transition: ${({ variant }) =>
    variant === "default" ? "none" : "transform 0.3s ease"};

  &:hover {
    transform: ${({ variant }) => (variant !== "default" ? "scale(1.05)" : "")};
  }

  &:active {
    transform: ${({ variant }) =>
      variant !== "default" ? "translateY(0)" : ""};
  }

  &:disabled {
    opacity: 0.6;
    cursor: not-allowed;
    transform: none;
  }
`;

Seletor de datas (SelectDate) — src/ui/components/filter/date/index.tsx

O que foi alterado:
  • Uso de useWindowSize() para determinar mobileDesign = width < 800.
  • Renderiza DateRange em mobile (com menos chrome) e DateRangePicker em desktop (com static ranges e visual completo).
javascript
"use client";

import { useEffect, useState } from "react";

import {
  createStaticRanges,
  DateRange,
  DateRangePicker,
} from "react-date-range";

import dayjs from "dayjs";
import weekday from "dayjs/plugin/weekday";
import isBetween from "dayjs/plugin/isBetween";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import { useRouter } from "next/navigation";

dayjs.extend(weekday);
dayjs.extend(isBetween);
dayjs.extend(isSameOrAfter);

import "react-date-range/dist/styles.css";
import "react-date-range/dist/theme/default.css";
import { Button } from "@/ui/Button";
import { useTheme } from "styled-components";
import { WrapperDate } from "./styled";
import { useWindowSize } from "@/hooks/useWindowSize";

export interface FilterRevenuesExpensesInterface {
  status: { id: number; name: string; value: string }[];
}

export const initialStateFilterRevenuesExpenses: FilterRevenuesExpensesInterface =
  {
    status: [],
  };

const initialStateFilter = {
  filter: {
    startDate: new Date(),
    endDate: new Date(),
    key: "filter",
  },
};

export const SelectDate = () => {
  const router = useRouter();
  const theme = useTheme();
  const { width } = useWindowSize();
  const mobileDesign = width < 800;

  const [dates, setDates] = useState(initialStateFilter);

  const customStaticRanges = createStaticRanges([
    {
      label: "Today",
      range: () => ({
        startDate: dayjs().toDate(),
        endDate: dayjs().toDate(),
      }),
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs(), "day") &&
        dayjs(range.endDate).isSame(dayjs(), "day"),
    },
    {
      label: "Current Week",
      range: () => ({
        startDate: dayjs().weekday(0).toDate(),
        endDate: dayjs().weekday(6).toDate(),
      }),
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().weekday(0), "day") &&
        dayjs(range.endDate).isSame(dayjs().weekday(6), "day"),
    },
    {
      label: "Current Month",
      range: () => ({
        startDate: dayjs().startOf("month").toDate(),
        endDate: dayjs().endOf("month").toDate(),
      }),
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().startOf("month"), "day") &&
        dayjs(range.endDate).isSame(dayjs().endOf("month"), "day"),
    },
    {
      label: "Last Month",
      range: () => {
        const lastMonth = dayjs().subtract(1, "month");
        return {
          startDate: lastMonth.startOf("month").toDate(),
          endDate: lastMonth.endOf("month").toDate(),
        };
      },
      isSelected: (range) => {
        const lastMonth = dayjs().subtract(1, "month");
        return (
          dayjs(range.startDate).isSame(lastMonth.startOf("month"), "day") &&
          dayjs(range.endDate).isSame(lastMonth.endOf("month"), "day")
        );
      },
    },
    {
      label: "This Year",
      range: () => ({
        startDate: dayjs().startOf("year").toDate(),
        endDate: dayjs().endOf("year").toDate(),
      }),
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().startOf("year"), "day") &&
        dayjs(range.endDate).isSame(dayjs().endOf("year"), "day"),
    },
    {
      label: "Total Period",
      range: () => {
        return {
          startDate: dayjs().toDate(),
          endDate: dayjs().toDate(),
        };
      },
      isSelected: (range) =>
        dayjs(range.startDate).isSame(dayjs().startOf("year"), "day") &&
        dayjs(range.endDate).isSame(dayjs().endOf("year"), "day"),
    },
  ]);

  const onChange = (newDate: any) => {
    if (newDate["filter"]) {
      setDates({ filter: newDate["filter"] });
      const startDate = dayjs(newDate["filter"].startDate).toISOString();
      const endDate = dayjs(newDate["filter"].endDate).toISOString();
      router.push(`?start=${startDate}&end=${endDate}`);
    }
  };

  return (
    <WrapperDate>
      {mobileDesign ? (
        <DateRange
          editableDateInputs={true}
          moveRangeOnFirstSelection={false}
          onChange={(item) => onChange(item)}
          ranges={[dates.filter]}
        />
      ) : (
        <DateRangePicker
          rangeColors={[theme.colors.primary]}
          onChange={(item) => onChange(item)}
          moveRangeOnFirstSelection={false}
          ranges={[dates.filter]}
          direction="horizontal"
          staticRanges={customStaticRanges}
        />
      )}
      <Button
        variant="default"
        onClick={() => {
          setDates(initialStateFilter);
          router.push(`?`);
        }}
        style={{ margin: "10px  0" }}
      >
        Resetar
      </Button>
    </WrapperDate>
  );
};

Sidebar (src/ui/components/sidebar.ts)

O que foi alterado:
  • Sidebar agora define width dependendo do estado $collapsed e do tamanho de tela.
  • Usa useWindowSize() para automaticamente colapsar em telas menores (width < 500).
  • Usa ResizeObserver e offsetWidth para atualizar a variável CSS -sidebar-width dinamicamente.
javascript
import { useEffect, useRef, useState } from "react";
import styled from "styled-components";
import {
  Home,
  SquareChevronRight,
  SquareChevronLeft,
  LogOut,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Cookies from "js-cookie";
import { useWindowSize } from "@/hooks/useWindowSize";

const SidebarContainer = styled.div<{ $collapsed: boolean }>`
  width: ${({ $collapsed }) => ($collapsed ? "50px" : "130px")};
  height: 100vh;
  background-color: ${({ theme }) => theme.colors.primary};
  color: #ffffff;
  display: flex;
  flex-direction: column;
  padding: 20px 10px;
  transition: width 0.3s ease;
  position: sticky;
  top: 0;
  box-sizing: border-box;
`;

const ToggleButton = styled.button<{ $collapsed: boolean }>`
  background: none;
  border: none;
  color: #ffffff;
  font-size: 1.5rem;
  cursor: pointer;
  margin-bottom: 20px;
  align-self: ${({ $collapsed }) => ($collapsed ? "center" : "flex-end")};
`;

const SidebarItem = styled.div<{ $collapsed: boolean }>`
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 15px 0;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 1;
  transition: all 0.2s ease;
  justify-content: center;

  span {
    display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")};
  }
`;

export const Sidebar = () => {
  const router = useRouter();
  const [collapsed, setCollapsed] = useState(false);
  const ref = useRef<HTMLDivElement | null>(null);

  const { width } = useWindowSize();
  const mobileDesign = width < 500;

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const setVar = () => {
      const w = el.offsetWidth;
      document.documentElement.style.setProperty("--sidebar-width", `${w}px`);
    };

    setVar();

    let ro: ResizeObserver | null = null;
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(() => requestAnimationFrame(setVar));
      ro.observe(el);
    }

    const onResize = () => requestAnimationFrame(setVar);
    window.addEventListener("resize", onResize);

    return () => {
      window.removeEventListener("resize", onResize);
      if (ro) ro.disconnect();
    };
  }, [collapsed]);

  useEffect(() => {
    if (mobileDesign) {
      setCollapsed(true);
    }
  }, [width]);

  return (
    <SidebarContainer ref={ref} $collapsed={collapsed}>
      {!mobileDesign && (
        <ToggleButton
          onClick={() => setCollapsed((s) => !s)}
          $collapsed={collapsed}
        >
          {collapsed ? <SquareChevronRight /> : <SquareChevronLeft />}
        </ToggleButton>
      )}

      <SidebarItem $collapsed={collapsed}>
        <Home size={20} /> <span>Home</span>
      </SidebarItem>

      <SidebarItem
        onClick={() => {
          Cookies.remove("token");
          router.push("/");
        }}
        $collapsed={collapsed}
        style={{ marginTop: "auto" }}
      >
        <LogOut size={20} /> <span>Logout</span>
      </SidebarItem>
    </SidebarContainer>
  );
};

Header (src/ui/components/header.tsx)

O que foi alterado:
  • Header reformatado para ser responsivo: TopRow empilha controles em telas estreitas; Controls e ControlGroup aceitam wrap.
  • Calcula e define -header-height via ResizeObserver para que ContentWrapper saiba o espaço disponível verticalmente.
  • Tags responsivas: usam flex-wrap e limitam largura em mobile, garantindo que a lista de filtros aplicados não quebre o layout.
typescript
"use client";

import styled from "styled-components";
import { SelectDate } from "./filter/date";
import { Tooltip } from "@/ui/Tooltip";
import { useEffect, useState, useCallback, useRef } from "react";
import dayjs from "dayjs";
import { Button } from "@/ui/Button";
import { AccountFilter } from "./filter/account";
import { IndustryFilter } from "./filter/industry";
import { StateFilter } from "./filter/state";
import { useSearchParams, useRouter } from "next/navigation";
import { TypeFilter } from "./filter/type";

const HeaderContainer = styled.header`
  width: 100%;
  background-color: ${({ theme }) => theme.colors.surface};
  color: ${({ theme }) => theme.colors.text};
  padding: 12px 20px;
  box-sizing: border-box;
  border-bottom: 1px solid ${({ theme }) => theme.colors.border};
  @media (min-width: 720px) {
    padding-right: 70px;
  }
`;

const TopRow = styled.div`
  display: flex;
  gap: 16px;
  align-items: center;
  justify-content: space-between;
  width: 100%;

  @media (max-width: 720px) {
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }
`;

const Title = styled.h1`
  margin: 0;
  font-size: 18px;
  font-weight: 700;
  @media (max-width: 720px) {
    height: 40px;
  }
`;

const Controls = styled.div`
  display: flex;
  gap: 12px;
  align-items: center;
  min-width: 0;

  @media (max-width: 720px) {
    justify-content: space-between;
  }
`;

const TagsRow = styled.div`
  margin-top: 10px;
  display: flex;
  gap: 8px;
  align-items: center;
  width: 100%;
  overflow-x: auto;
  padding-bottom: 6px;
  overflow-x: auto;
  flex-wrap: wrap;
  justify-content: flex-start;
  &::-webkit-scrollbar {
    height: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background: ${({ theme }) => theme.colors.border};
    border-radius: 6px;
  }
`;

const TagFilter = styled.div`
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 100%;
  padding: 10px 15px;
  border-radius: 999px;
  background: ${({ theme }) => `${theme.colors.primary}22`};
  color: ${({ theme }) => theme.colors.primary};
  font-weight: 600;
  font-size: 13px;
  white-space: nowrap;
  flex-shrink: 0;
`;

const TagMain = styled.div`
  display: inline-flex;
  align-items: center;
  gap: 8px;
  @media (max-width: 720px) {
    max-width: 100px;
    flex-direction: column;
    align-items: flex-start;
  }
`;

const TagType = styled.span`
  display: inline-block;
  padding: 2px 6px;
  border-radius: 6px;
  background: ${({ theme }) => `${theme.colors.primary}33`};
  color: ${({ theme }) => theme.colors.primary};
  font-size: 11px;
  text-transform: uppercase;
  font-weight: 700;
  + span {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    text-overflow: ellipsis;
    word-break: break-word;
    @media (max-width: 720px) {
      max-width: 100%;
    }
  }
`;

const TagClose = styled.button`
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: inherit;
  cursor: pointer;
  padding: 0;
  font-size: 12px;
  line-height: 1;
`;

const ControlGroup = styled.div`
  display: flex;
  gap: 8px;
  align-items: flex-start;
  flex-wrap: wrap;
  justify-content: space-between;
`;

type TagItem = {
  type: "account" | "industry" | "state" | "type";
  value: string;
};

export const Header = ({
  filterOptions,
}: {
  filterOptions: {
    account: string[];
    industry: string[];
    state: string[];
    transaction_type: string[];
  };
}) => {
  const [labelCalendar, setLabelCalendar] = useState("Total Period");

  const searchParams = useSearchParams();
  const router = useRouter();

  const dateIni = searchParams.get("start") ?? "";
  const dateFim = searchParams.get("end") ?? "";

  const accountFilter = searchParams.getAll("account");
  const industryFilter = searchParams.getAll("industry");
  const stateFilter = searchParams.getAll("state");
  const typeFilter = searchParams.getAll("type");

  const combinedTags: TagItem[] = [
    ...accountFilter.map((v) => ({ type: "account" as const, value: v })),
    ...industryFilter.map((v) => ({ type: "industry" as const, value: v })),
    ...stateFilter.map((v) => ({ type: "state" as const, value: v })),
    ...typeFilter.map((v) => ({ type: "type" as const, value: v })),
  ];

  useEffect(() => {
    if (!dateIni && !dateFim) {
      setLabelCalendar("Total Period");
      return;
    }

    const start = dayjs(dateIni);
    const end = dayjs(dateFim);

    const isSameYear =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().startOf("year"), "day") &&
      end.isSame(dayjs().endOf("year"), "day");

    if (isSameYear) {
      setLabelCalendar("This Year");
      return;
    }   

    const lastMonth = dayjs().subtract(1, "month");
    const isLastMonth =
      start.isValid() &&
      end.isValid() &&
      start.isSame(lastMonth.startOf("month"), "day") &&
      end.isSame(lastMonth.endOf("month"), "day");

    if (isLastMonth) {
      setLabelCalendar("Last Month");
      return;
    }

    const isSameMonth =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().startOf("month"), "day") &&
      end.isSame(dayjs().endOf("month"), "day");

    if (isSameMonth) {
      setLabelCalendar("Current Month");
      return;
    }

    const isSameWeek =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs().weekday(0), "day") &&
      end.isSame(dayjs().weekday(6), "day");

    if (isSameWeek) {
      setLabelCalendar("Current Week");
      return;
    }

    const today =
      start.isValid() &&
      end.isValid() &&
      start.isSame(dayjs(), "day") &&
      end.isSame(dayjs(), "day");

    if (today) {
      setLabelCalendar("Today");
      return;
    }

    if (start.isValid() && end.isValid()) {
      // Formato inglês MM/DD
      setLabelCalendar(`${start.format("MM/DD")} - ${end.format("MM/DD")}`);
    } else {
      setLabelCalendar("Total Period");
    }
  }, [dateIni, dateFim]);

  const headerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    const setHeaderVar = () => {
      const h = headerRef.current?.offsetHeight ?? 0;

      document.documentElement.style.setProperty("--header-height", `${h}px`);
    };

    const update = () => requestAnimationFrame(setHeaderVar);

    update();

    let ro: ResizeObserver | null = null;
    if (typeof ResizeObserver !== "undefined" && headerRef.current) {
      ro = new ResizeObserver(update);
      ro.observe(headerRef.current);
    }

    window.addEventListener("resize", update);

    return () => {
      window.removeEventListener("resize", update);
      if (ro) ro.disconnect();
    };
  }, []);

  const removeFilter = useCallback(
    (type: TagItem["type"], value: string) => {
      const params = new URLSearchParams(searchParams.toString());
      const existing = params.getAll(type).filter((v) => v !== value);
      params.delete(type);
      existing.forEach((v) => params.append(type, v));
      const qs = params.toString();

      router.push(`?${qs}`);
    },
    [searchParams, router]
  );

  return (
    <HeaderContainer ref={headerRef}>
      <TopRow>
        <Title>Dashboard</Title>

        <Controls>
          <ControlGroup>
            <AccountFilter options={filterOptions.account} />
            <IndustryFilter options={filterOptions.industry} />
            <StateFilter options={filterOptions.state} />
            <TypeFilter options={filterOptions.transaction_type} />
            <Tooltip content={<SelectDate />}>
              <Button variant="default">{labelCalendar}</Button>
            </Tooltip>
          </ControlGroup>
        </Controls>
      </TopRow>

      {/* Tags */}
      {combinedTags.length > 0 && (
        <TagsRow aria-label="Filtros aplicados">
          {combinedTags.map((tag) => (
            <TagFilter key={`${tag.type}-${tag.value}`}>
              <TagMain>
                <TagType>{tag.type}</TagType>
                <span>{tag.value}</span>
              </TagMain>
              <TagClose
                aria-label={`Remover filtro ${tag.value}`}
                onClick={() => removeFilter(tag.type, tag.value)}
              >
                ×
              </TagClose>
            </TagFilter>
          ))}
        </TagsRow>
      )}
    </HeaderContainer>
  );
};

Layout do Dashboard (src/ui/components/dashbord/styled.ts)

O que foi alterado:
  • DashboardContainer usa display: grid com grid-template-columns: var(--sidebar-width, 150px) 1fr para que a largura da primeira coluna reflita a sidebar.
  • _MainContent e ContentWrappe_r usam min-width: 0 e minmax(220px, 1fr) para melhorar encolhimento e evitar overflow.
  • max-height: calc(100vh - var(--header-height, 130px)) garante que o scroll vertical do conteúdo respeite o header.
javascript
import styled, { keyframes } from "styled-components";


export const DashboardContainer = styled.div`
  display: grid;
  grid-template-columns: var(--sidebar-width, 150px) 1fr;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
`;


export const MainContent = styled.div`
  flex: 1;
  min-width: 0;
  background-color: ${({ theme }) => theme.colors.background};
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  max-width: calc(100vw - var(--sidebar-width, 150px));
`;

export const ContentWrapper = styled.div`
  padding: 20px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 20px;
  overflow-y: auto;
  max-height: calc(100vh - var(--header-height, 130px));
  width: 100%;
  box-sizing: border-box;

  & > * {
    min-width: 0;
  }
`;

export const spin = keyframes`
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
`;

export const Spinner = styled.div`
  border: 3px solid #f3f3f3;
  border-top: 3px solid ${({ theme }) => theme.colors.primary};
  border-radius: 50%;
  width: 20px;
  height: 20px;
  animation: ${spin} 1s linear infinite;
  margin: 10px auto;
`;

export const TransactionsTableWrapper = styled.div`
  grid-column: 1 / -1;
  width: 100%;
  margin-top: 0px;
`;

Charts (src/ui/components/charts.tsx)

O que foi alterado:
  • Importa e usa useWindowSize() para calcular mobileDesign (ex.: width < 500).
  • Cada ResponsiveContainer tem minWidth={800} e height que varia conforme mobileDesign (ex.: 300 ou 450).
  • Containers permitem overflow-x: auto e webkit-overflow-scrolling: touch para smooth scrolling em mobile.
typescript
import React, { useMemo } from "react";
import {
  BarChart as ReBarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  Legend,
  LineChart as ReLineChart,
  Line,
  ResponsiveContainer,
} from "recharts";
import type { ITransaction } from "@/types/transaction";
import styled, { useTheme } from "styled-components";
import { useWindowSize } from "@/hooks/useWindowSize";

const ChartsWrapper = styled.div`
  p {
    font-weight: bold;
    margin-top: 20px;
  }
`;

const ChartsContainer = styled.div`
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: 1fr;
  gap: 24px;
  width: 100%;
  box-sizing: border-box;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;

  p {
    font-weight: bold;
    margin-top: 20px;
  }
`;

function prepareChartDataOptimized(data: ITransaction[], targetPoints = 1000) {
  const parseAmount = (amountStr: string | number) => {
    const n = Number(amountStr) || 0;
    return n / 100;
  };

  const industryMap = new Map<string, { deposit: number; withdraw: number }>();
  const accountMap = new Map<string, { deposit: number; withdraw: number }>();
  const deltas: { ts: number; delta: number }[] = [];

  for (let i = 0; i < data.length; i++) {
    const tx = data[i];
    const amt = parseAmount(tx.amount);

    // === Industry aggregation ===
    const ind = (tx.industry ?? "Unknown").toString();
    if (!industryMap.has(ind))
      industryMap.set(ind, { deposit: 0, withdraw: 0 });
    const curInd = industryMap.get(ind)!;
    if (tx.transaction_type === "deposit") curInd.deposit += amt;
    else curInd.withdraw += amt;

    // === Account aggregation (saldo) ===
    const acc = (tx.account ?? "Unknown").toString().trim() || "Unknown";
    if (!accountMap.has(acc)) accountMap.set(acc, { deposit: 0, withdraw: 0 });
    const curAcc = accountMap.get(acc)!;
    if (tx.transaction_type === "deposit") curAcc.deposit += amt;
    else curAcc.withdraw += amt;

    // === Timeline deltas ===
    const ts =
      typeof tx.date === "number" ? tx.date : new Date(tx.date).getTime();
    const delta = tx.transaction_type === "deposit" ? amt : -amt;
    if (!Number.isFinite(ts)) continue;
    deltas.push({ ts, delta });
  }

  // === Bar chart data ===
  const barChartData = Array.from(industryMap.entries()).map(
    ([industry, v]) => ({
      industry,
      deposit: v.deposit,
      withdraw: v.withdraw,
    })
  );

  // === Heatmap data (saldo por conta) ===
  const heatmapData = Array.from(accountMap.entries())
    .map(([account, v]) => ({
      account,
      balance: v.deposit - v.withdraw,
    }))
    .sort((a, b) => b.balance - a.balance); // do maior para o menor saldo

  // === Line chart data ===
  deltas.sort((a, b) => a.ts - b.ts);
  const N = deltas.length;
  const finalLine: { date: string; balance: number }[] = [];

  if (N === 0) return { barChartData, lineChartData: [], heatmapData };

  if (N <= targetPoints) {
    let cumulative = 0;
    for (let i = 0; i < N; i++) {
      cumulative += deltas[i].delta;
      finalLine.push({
        date: new Date(deltas[i].ts).toLocaleDateString("pt-BR"),
        balance: cumulative,
      });
    }
  } else {
    // Downsampling por bucket
    const minTs = deltas[0].ts;
    const maxTs = deltas[N - 1].ts;
    const range = Math.max(1, maxTs - minTs);
    const bucketMs = Math.ceil(range / targetPoints);

    const buckets = new Map<
      number,
      { tsSum: number; deltaSum: number; count: number }
    >();
    for (let i = 0; i < N; i++) {
      const { ts, delta } = deltas[i];
      const idx = Math.floor((ts - minTs) / bucketMs);
      const cur = buckets.get(idx);
      if (!cur) buckets.set(idx, { tsSum: ts, deltaSum: delta, count: 1 });
      else {
        cur.tsSum += ts;
        cur.deltaSum += delta;
        cur.count += 1;
      }
    }

    let cumulative = 0;
    const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
    for (let k = 0; k < keys.length; k++) {
      const b = buckets.get(keys[k])!;
      const avgTs = Math.round(b.tsSum / b.count);
      cumulative += b.deltaSum;
      finalLine.push({
        date: new Date(avgTs).toLocaleDateString("pt-BR"),
        balance: cumulative,
      });
    }
  }

  return { barChartData, lineChartData: finalLine, heatmapData };
}

export const Charts = ({ data }: { data: ITransaction[] }) => {
  const theme = useTheme();
  const { width } = useWindowSize();
  const mobileDesign = width < 500;

  const { barChartData, lineChartData, heatmapData } = useMemo(() => {
    return prepareChartDataOptimized(data, 1200);
  }, [data]);

  const currencyFormatter = (value: number) =>
    value.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
  return (
    <>
      <ChartsWrapper>
        <p>Total deposits and withdrawals per sector</p>
        <ChartsContainer>
          <ResponsiveContainer
            width="100%"
            minWidth={800}
            height={mobileDesign ? 300 : 450}
          >
            <ReBarChart
              data={barChartData}
              margin={{ top: 20, right: 60, left: 60, bottom: 5 }}
            >
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis
                dataKey="industry"
                interval={0}
                angle={-20}
                stroke={theme.colors.text}
                textAnchor="end"
                height={70}
                tick={{ fontSize: 12 }}
              />
              <YAxis
                stroke={theme.colors.text}
                tickFormatter={currencyFormatter}
              />
              <Tooltip
                contentStyle={{ backgroundColor: theme.colors.background }}
                formatter={(v) => currencyFormatter(Number(v))}
              />
              <Legend />
              <Bar dataKey="deposit" fill="#4caf50" />
              <Bar dataKey="withdraw" fill="#f44336" />
            </ReBarChart>
          </ResponsiveContainer>
        </ChartsContainer>
      </ChartsWrapper>

      <ChartsWrapper>
        <p>Total balance over time</p>
        <ChartsContainer>
          <ResponsiveContainer
            width="100%"
            minWidth={800}
            height={mobileDesign ? 300 : 450}
          >
            <ReLineChart
              data={lineChartData}
              margin={{ top: 20, right: 50, left: 50, bottom: 5 }}
            >
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis
                stroke={theme.colors.text}
                dataKey="date"
                interval={Math.max(0, Math.floor(lineChartData.length / 8))}
              />
              <YAxis
                stroke={theme.colors.text}
                tickFormatter={currencyFormatter}
              />
              <Tooltip
                contentStyle={{ backgroundColor: theme.colors.background }}
                formatter={(v) => currencyFormatter(Number(v))}
              />
              <Line
                type="monotone"
                dataKey="balance"
                stroke={theme.colors.secondary}
                dot={false}
              />
            </ReLineChart>
          </ResponsiveContainer>
        </ChartsContainer>
      </ChartsWrapper>

      <ChartsWrapper>
        <p>Net balance of each account</p>
        <ChartsContainer>
          <ResponsiveContainer
            width="100%"
            minWidth={800}
            height={mobileDesign ? 600 : 800}
          >
            <ReBarChart
              layout="vertical"
              data={heatmapData}
              margin={{ top: 20, right: 50, left: 120, bottom: 5 }}
            >
              <XAxis
                type="number"
                stroke={theme.colors.text}
                tickFormatter={currencyFormatter}
              />
              <YAxis
                stroke={theme.colors.text}
                type="category"
                angle={-10}
                tick={{ fontSize: 12 }}
                dataKey="account"
              />
              <Tooltip
                contentStyle={{ backgroundColor: theme.colors.background }}
                formatter={(value) => currencyFormatter(Number(value))}
              />
              <Bar dataKey="balance" fill="#03a9f4" />
            </ReBarChart>
          </ResponsiveContainer>
        </ChartsContainer>
      </ChartsWrapper>
    </>
  );
};

Card (src/ui/components/Card.tsx)

O que foi alterado:
  • Card tem min-width: 0, word-break: break-word, overflow-wrap: anywhere e regras de webkit-line-clamp para truncar texto quando necessário.
typescript
import styled from "styled-components";

const CardContainer = styled.div`
  background-color: ${({ theme }) => theme.colors.surface};
  padding: 20px;
  border-radius: 10px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  width: 100%;
  box-sizing: border-box;

  min-width: 0;
  min-height: 0;

  overflow: hidden;

  word-break: break-word;
  overflow-wrap: anywhere;

  & * {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    text-overflow: ellipsis;
    word-break: break-word;
    white-space: normal;
  }
`;

export const Card = ({ children }: { children: React.ReactNode }) => (
  <CardContainer>{children}</CardContainer>
);