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
bashnpx 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
bashnpm 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
typescriptimport '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
typescriptimport { 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:
bashnpm 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:
typescriptimport 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:
typescriptimport 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.tsx — login
- 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 :
typescriptimport 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
typescriptimport { 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
typescriptimport 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
typescriptimport 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
typescriptimport 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
typescriptimport { 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:
typescriptimport { 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:
typescriptimport { 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:
- Sidebar – navegação lateral e logout
- Header – topo da página com título
- 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:
typescriptimport { 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:
typescriptimport 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:
typescriptimport 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
typescriptexport 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
typescriptexport 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
typescriptimport { 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} > < </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} > > </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
typescriptimport 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
typescriptexport 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
typescriptimport { 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
typescriptimport 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
typescriptimport 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
typescriptimport 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
typescriptimport { 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
typescriptimport 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.
bashnpm 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
typescriptimport 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
javascriptimport 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
typescriptimport { 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
typescriptimport 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
javascriptimport 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
javascriptimport 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
javascriptimport 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.
javascriptimport { 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
javascriptimport { 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.
javascriptimport 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; } `;
codesrc/ui/Table/*
(index + styled)
code
src/ui/Table/*
O que foi alterado:
- Introdução de ecode
TableContainer
comcodeTableWrapper
para permitir scroll horizontal em telas pequenas.codeoverflow-x: auto
- temcode
Table
(ou outro valor) para preservar colunas legíveis; o wrapper permite scroll em dispositivos móveis.codemin-width: 500px
- Paginação: botão de pular 10 páginas (ecode
<<
) ecode>>
com padding reduzido em telas < 480px.codePageButton
src/ui/Table/index.tsx
javascriptimport { 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} > < < </PageButton> <PageButton onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))} disabled={currentPage === 1} > < </PageButton> {pages.map((p) => ( <PageButton key={p} $active={p === currentPage}> {p} </PageButton> ))} <PageButton onClick={() => setCurrentPage(Math.min(currentPage + 1, totalPages)) } disabled={currentPage === totalPages} > > </PageButton> <PageButton onClick={() => setCurrentPage(Math.min(currentPage + 10, totalPages)) } disabled={currentPage === totalPages} > >> </PageButton> </PaginationInner> </PaginationWrapper> </TableContainer> ); };
src/ui/Table/styled.ts
javascriptimport 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.
javascriptimport 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.
javascriptimport { 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.
javascriptimport 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.
typescriptimport 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.
typescriptimport 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> );