feat(relatorios): reorganizar páginas e criar componente CategoryIconBadge

- Renomear /relatorios/categorias para /relatorios/tendencias
- Renomear /relatorios/cartoes para /relatorios/uso-cartoes
- Criar componente CategoryIconBadge unificado com cores dinâmicas
- Atualizar cards de categorias com novo layout (ações no footer)
- Atualizar cards de orçamentos com CategoryIconBadge
- Adicionar tooltip detalhado nas células de tendências (valor anterior e diferença)
- Adicionar dot colorido (verde/vermelho) para indicar tipo de categoria

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-30 14:52:11 +00:00
parent 11f85e4b28
commit fd84a0d1ac
25 changed files with 611 additions and 404 deletions

View File

@@ -58,7 +58,7 @@ export default async function Page({ searchParams }: PageProps) {
if (!validation.isValid) { if (!validation.isValid) {
// Redirect to default if validation fails // Redirect to default if validation fails
redirect( redirect(
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`, `/relatorios/tendencias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
); );
} }

View File

@@ -130,10 +130,11 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categoriesByType[type].map((category) => ( {categoriesByType[type].map((category, index) => (
<CategoryCard <CategoryCard
key={category.id} key={category.id}
category={category} category={category}
colorIndex={index}
onEdit={handleEdit} onEdit={handleEdit}
onRemove={handleRemoveRequest} onRemove={handleRemoveRequest}
/> />

View File

@@ -1,94 +1,103 @@
"use client"; "use client";
import { RiDeleteBin5Line, RiMore2Fill, RiPencilLine } from "@remixicon/react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { import {
DropdownMenu, RiDeleteBin5Line,
DropdownMenuContent, RiFileList2Line,
DropdownMenuItem, RiPencilLine,
DropdownMenuTrigger, } from "@remixicon/react";
} from "@/components/ui/dropdown-menu"; import Link from "next/link";
import { TypeBadge } from "../type-badge"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { CategoryIcon } from "./category-icon"; import { cn } from "@/lib/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
import type { Category } from "./types"; import type { Category } from "./types";
interface CategoryCardProps { interface CategoryCardProps {
category: Category; category: Category;
colorIndex: number;
onEdit: (category: Category) => void; onEdit: (category: Category) => void;
onRemove: (category: Category) => void; onRemove: (category: Category) => void;
} }
export function CategoryCard({ export function CategoryCard({
category, category,
colorIndex,
onEdit, onEdit,
onRemove, onRemove,
}: CategoryCardProps) { }: CategoryCardProps) {
// Categorias protegidas que não podem ser editadas ou removidas
const categoriasProtegidas = [ const categoriasProtegidas = [
"Transferência interna", "Transferência interna",
"Saldo inicial", "Saldo inicial",
"Pagamentos", "Pagamentos",
]; ];
const isProtegida = categoriasProtegidas.includes(category.name); const isProtegida = categoriasProtegidas.includes(category.name);
const canEdit = !isProtegida;
const canRemove = !isProtegida; const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: () => onEdit(category),
variant: "default" as const,
disabled: isProtegida,
},
{
label: "detalhes",
icon: <RiFileList2Line className="size-4" aria-hidden />,
href: `/categorias/${category.id}`,
variant: "default" as const,
disabled: false,
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: () => onRemove(category),
variant: "destructive" as const,
disabled: isProtegida,
},
].filter((action) => !action.disabled);
return ( return (
<Card className="group py-2"> <Card className="flex h-full flex-col gap-0 py-3">
<CardContent className="p-2"> <CardContent className="flex flex-1 flex-col">
<div className="flex items-start justify-between gap-3"> <div className="flex items-center gap-3">
<div className="flex items-start gap-2"> <CategoryIconBadge
<span className="flex size-11 items-center justify-center text-primary"> icon={category.icon}
<CategoryIcon name={category.icon} className="size-6" /> name={category.name}
</span> colorIndex={colorIndex}
<div className="space-y-1"> size="md"
<h3 className="text-base font-medium leading-tight"> />
<Link <h3 className="leading-tight">{category.name}</h3>
href={`/categorias/${category.id}`}
className="underline-offset-4 hover:underline"
>
{category.name}
</Link>
</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<TypeBadge type={category.type} />
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 transition-opacity group-hover:opacity-100"
>
<RiMore2Fill className="size-4" />
<span className="sr-only">Abrir ações da categoria</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEdit(category)}
disabled={!canEdit}
>
<RiPencilLine className="mr-2 size-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onRemove(category)}
disabled={!canRemove}
>
<RiDeleteBin5Line className="mr-2 size-4" />
Remover
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex flex-wrap gap-3 px-6 pt-4 text-sm">
{actions.map(({ label, icon, onClick, href, variant }) => {
const className = cn(
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
variant === "destructive" ? "text-destructive" : "text-primary",
);
if (href) {
return (
<Link key={label} href={href} className={className}>
{icon}
{label}
</Link>
);
}
return (
<button
key={label}
type="button"
onClick={onClick}
className={className}
>
{icon}
{label}
</button>
);
})}
</CardFooter>
</Card> </Card>
); );
} }

View File

@@ -1,25 +1,11 @@
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react"; import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import type { CategoryType } from "@/lib/categorias/constants"; import type { CategoryType } from "@/lib/categorias/constants";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers"; import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { getIconComponent } from "@/lib/utils/icons";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
import { TypeBadge } from "../type-badge"; import { TypeBadge } from "../type-badge";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
};
type CategorySummary = { type CategorySummary = {
id: string; id: string;
name: string; name: string;
@@ -46,9 +32,6 @@ export function CategoryDetailHeader({
percentageChange, percentageChange,
transactionCount, transactionCount,
}: CategoryDetailHeaderProps) { }: CategoryDetailHeaderProps) {
const IconComponent = category.icon ? getIconComponent(category.icon) : null;
const initials = buildInitials(category.name);
const isIncrease = const isIncrease =
typeof percentageChange === "number" && percentageChange > 0; typeof percentageChange === "number" && percentageChange > 0;
const isDecrease = const isDecrease =
@@ -87,15 +70,12 @@ export function CategoryDetailHeader({
<Card className="px-4"> <Card className="px-4">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex size-12 items-center justify-center rounded-xl bg-muted"> <CategoryIconBadge
{IconComponent ? ( icon={category.icon}
<IconComponent className="size-6" aria-hidden /> name={category.name}
) : ( colorIndex={0}
<span className="text-sm font-semibold uppercase text-muted-foreground"> size="lg"
{initials} />
</span>
)}
</span>
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight"> <h1 className="text-xl font-semibold leading-tight">
{category.name} {category.name}

View File

@@ -0,0 +1,78 @@
"use client";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { cn } from "@/lib/utils/ui";
const sizeVariants = {
sm: {
container: "size-8",
icon: "size-4",
text: "text-[10px]",
},
md: {
container: "size-9",
icon: "size-5",
text: "text-xs",
},
lg: {
container: "size-12",
icon: "size-6",
text: "text-sm",
},
} as const;
export type CategoryIconBadgeSize = keyof typeof sizeVariants;
export interface CategoryIconBadgeProps {
/** Nome do ícone Remix (ex: "RiShoppingBag3Line") */
icon?: string | null;
/** Nome da categoria (usado para gerar iniciais como fallback) */
name: string;
/** Índice para determinar a cor (cicla entre as cores disponíveis) */
colorIndex: number;
/** Tamanho do badge: sm (32px), md (36px), lg (48px) */
size?: CategoryIconBadgeSize;
/** Classes adicionais para o container */
className?: string;
}
export function CategoryIconBadge({
icon,
name,
colorIndex,
size = "md",
className,
}: CategoryIconBadgeProps) {
const IconComponent = icon ? getIconComponent(icon) : null;
const initials = buildCategoryInitials(name);
const color = getCategoryColor(colorIndex);
const bgColor = getCategoryBgColor(colorIndex);
const variant = sizeVariants[size];
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-lg",
variant.container,
className,
)}
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className={variant.icon} style={{ color }} />
) : (
<span
className={cn("font-semibold uppercase", variant.text)}
style={{ color }}
>
{initials}
</span>
)}
</div>
);
}

View File

@@ -12,15 +12,10 @@ import {
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts"; import { Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category"; import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period"; import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state"; import { WidgetEmptyState } from "../widget-empty-state";
@@ -162,12 +157,6 @@ export function ExpensesByCategoryWidgetWithChart({
<TabsContent value="list" className="mt-0"> <TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0"> <div className="flex flex-col px-0">
{data.categories.map((category, index) => { {data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const hasIncrease = const hasIncrease =
category.percentageChange !== null && category.percentageChange !== null &&
category.percentageChange > 0; category.percentageChange > 0;
@@ -192,21 +181,11 @@ export function ExpensesByCategoryWidgetWithChart({
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<div <CategoryIconBadge
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg" icon={category.categoryIcon}
style={{ backgroundColor: bgColor }} name={category.categoryName}
> colorIndex={index}
{IconComponent ? ( />
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -12,15 +12,10 @@ import {
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts"; import { Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category"; import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period"; import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state"; import { WidgetEmptyState } from "../widget-empty-state";
@@ -162,12 +157,6 @@ export function IncomeByCategoryWidgetWithChart({
<TabsContent value="list" className="mt-0"> <TabsContent value="list" className="mt-0">
<div className="flex flex-col px-0"> <div className="flex flex-col px-0">
{data.categories.map((category, index) => { {data.categories.map((category, index) => {
const IconComponent = category.categoryIcon
? getIconComponent(category.categoryIcon)
: null;
const initials = buildCategoryInitials(category.categoryName);
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const hasIncrease = const hasIncrease =
category.percentageChange !== null && category.percentageChange !== null &&
category.percentageChange > 0; category.percentageChange > 0;
@@ -192,21 +181,11 @@ export function IncomeByCategoryWidgetWithChart({
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<div <CategoryIconBadge
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg" icon={category.categoryIcon}
style={{ backgroundColor: bgColor }} name={category.categoryName}
> colorIndex={index}
{IconComponent ? ( />
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiDeleteBin5Line, RiPencilLine } from "@remixicon/react"; import { RiDeleteBin5Line, RiPencilLine } from "@remixicon/react";
import { CategoryIcon } from "@/components/categorias/category-icon"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
@@ -10,6 +10,7 @@ import type { Budget } from "./types";
interface BudgetCardProps { interface BudgetCardProps {
budget: Budget; budget: Budget;
colorIndex: number;
periodLabel: string; periodLabel: string;
onEdit: (budget: Budget) => void; onEdit: (budget: Budget) => void;
onRemove: (budget: Budget) => void; onRemove: (budget: Budget) => void;
@@ -28,6 +29,7 @@ const formatCategoryName = (budget: Budget) =>
export function BudgetCard({ export function BudgetCard({
budget, budget,
colorIndex,
periodLabel, periodLabel,
onEdit, onEdit,
onRemove, onRemove,
@@ -41,12 +43,12 @@ export function BudgetCard({
<Card className="flex h-full flex-col"> <Card className="flex h-full flex-col">
<CardContent className="flex h-full flex-col gap-4"> <CardContent className="flex h-full flex-col gap-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center text-primary"> <CategoryIconBadge
<CategoryIcon icon={budget.category?.icon ?? undefined}
name={budget.category?.icon ?? undefined} name={formatCategoryName(budget)}
className="size-6" colorIndex={colorIndex}
size="lg"
/> />
</span>
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-base font-semibold leading-tight"> <h3 className="text-base font-semibold leading-tight">
{formatCategoryName(budget)} {formatCategoryName(budget)}

View File

@@ -129,10 +129,11 @@ export function BudgetsPage({
{hasBudgets ? ( {hasBudgets ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{budgets.map((budget) => ( {budgets.map((budget, index) => (
<BudgetCard <BudgetCard
key={budget.id} key={budget.id}
budget={budget} budget={budget}
colorIndex={index}
periodLabel={periodLabel} periodLabel={periodLabel}
onEdit={handleEdit} onEdit={handleEdit}
onRemove={handleRemoveRequest} onRemove={handleRemoveRequest}

View File

@@ -1,17 +1,12 @@
"use client"; "use client";
import { RiPieChartLine } from "@remixicon/react"; import { RiPieChartLine } from "@remixicon/react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { WidgetEmptyState } from "@/components/widget-empty-state"; import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { CardDetailData } from "@/lib/relatorios/cartoes-report"; import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { title_font } from "@/public/fonts/font_index"; import { title_font } from "@/public/fonts/font_index";
type CardCategoryBreakdownProps = { type CardCategoryBreakdownProps = {
@@ -56,36 +51,18 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="flex flex-col"> <div className="flex flex-col">
{data.map((category, index) => { {data.map((category, index) => (
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const initials = buildCategoryInitials(category.name);
return (
<div <div
key={category.id} key={category.id}
className="flex flex-col py-2 border-b border-dashed last:border-0" className="flex flex-col py-2 border-b border-dashed last:border-0"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<div <CategoryIconBadge
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg" icon={category.icon}
style={{ backgroundColor: bgColor }} name={category.name}
> colorIndex={index}
{IconComponent ? ( />
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
{/* Name and percentage */} {/* Name and percentage */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -108,12 +85,11 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
</div> </div>
{/* Progress bar */} {/* Progress bar */}
<div className="ml-12 mt-1.5"> <div className="ml-11 mt-1.5">
<Progress className="h-1.5" value={category.percent} /> <Progress className="h-1.5" value={category.percent} />
</div> </div>
</div> </div>
); ))}
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -60,7 +60,7 @@ export function CardTopExpenses({ data }: CardTopExpensesProps) {
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
{/* Rank number */} {/* Rank number */}
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"> <div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
<span className="text-sm font-semibold text-muted-foreground"> <span className="text-sm font-semibold text-muted-foreground">
{index + 1} {index + 1}
</span> </span>

View File

@@ -67,7 +67,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (periodoParam) params.set("periodo", periodoParam); if (periodoParam) params.set("periodo", periodoParam);
params.set("cartao", cardId); params.set("cartao", cardId);
return `/relatorios/cartoes?${params.toString()}`; return `/relatorios/uso-cartoes?${params.toString()}`;
}; };
const summaryCards = [ const summaryCards = [
@@ -140,7 +140,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
alt={card.name} alt={card.name}
width={32} width={32}
height={32} height={32}
className="rounded object-contain" className="rounded-sm object-contain"
/> />
) : ( ) : (
<RiBankCard2Line className="size-5 text-muted-foreground" /> <RiBankCard2Line className="size-5 text-muted-foreground" />

View File

@@ -1,6 +1,11 @@
"use client"; "use client";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react"; import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatCurrency, formatPercentageChange } from "@/lib/relatorios/utils"; import { formatCurrency, formatPercentageChange } from "@/lib/relatorios/utils";
import { cn } from "@/lib/utils/ui"; import { cn } from "@/lib/utils/ui";
@@ -22,11 +27,15 @@ export function CategoryCell({
? ((value - previousValue) / previousValue) * 100 ? ((value - previousValue) / previousValue) * 100
: null; : null;
const absoluteChange = !isFirstMonth ? value - previousValue : null;
const isIncrease = percentageChange !== null && percentageChange > 0; const isIncrease = percentageChange !== null && percentageChange > 0;
const isDecrease = percentageChange !== null && percentageChange < 0; const isDecrease = percentageChange !== null && percentageChange < 0;
return ( return (
<div className="flex flex-col items-end gap-0.5 min-h-9"> <Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col items-end gap-0.5 min-h-9 justify-center cursor-default px-4 py-2">
<span className="font-medium">{formatCurrency(value)}</span> <span className="font-medium">{formatCurrency(value)}</span>
{!isFirstMonth && percentageChange !== null && ( {!isFirstMonth && percentageChange !== null && (
<div <div
@@ -42,5 +51,31 @@ export function CategoryCell({
</div> </div>
)} )}
</div> </div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="flex flex-col gap-1">
<div className="font-medium">{formatCurrency(value)}</div>
{!isFirstMonth && absoluteChange !== null && (
<>
<div className="font-bold">
Mês anterior: {formatCurrency(previousValue)}
</div>
<div
className={cn(
"font-medium",
isIncrease && "text-red-500",
isDecrease && "text-green-500",
)}
>
Diferença:{" "}
{absoluteChange >= 0
? `+${formatCurrency(absoluteChange)}`
: formatCurrency(absoluteChange)}
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
); );
} }

View File

@@ -1,31 +1,45 @@
"use client"; "use client";
import { TypeBadge } from "@/components/type-badge"; import Link from "next/link";
import { useMemo } from "react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { CategoryReportData } from "@/lib/relatorios/types"; import type {
CategoryReportData,
CategoryReportItem,
} from "@/lib/relatorios/types";
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils"; import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
import { getIconComponent } from "@/lib/utils/icons"; import { formatPeriodForUrl } from "@/lib/utils/period";
import { CategoryCell } from "./category-cell"; import { CategoryCell } from "./category-cell";
interface CategoryReportCardsProps { interface CategoryReportCardsProps {
data: CategoryReportData; data: CategoryReportData;
} }
export function CategoryReportCards({ data }: CategoryReportCardsProps) { interface CategoryCardProps {
const { categories, periods } = data; category: CategoryReportItem;
periods: string[];
colorIndex: number;
}
function CategoryCard({ category, periods, colorIndex }: CategoryCardProps) {
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
return ( return (
<div className="md:hidden space-y-4"> <Card>
{categories.map((category) => { <CardHeader className="pb-3">
const Icon = category.icon ? getIconComponent(category.icon) : null; <CardTitle className="flex items-center gap-3">
<CategoryIconBadge
return ( icon={category.icon}
<Card key={category.categoryId}> name={category.name}
<CardHeader> colorIndex={colorIndex}
<CardTitle className="flex items-center gap-2"> />
{Icon && <Icon className="h-5 w-5 shrink-0" />} <Link
<span className="flex-1 truncate">{category.name}</span> href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
<TypeBadge type={category.type} /> className="flex-1 truncate hover:underline underline-offset-2"
>
{category.name}
</Link>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@@ -57,7 +71,91 @@ export function CategoryReportCards({ data }: CategoryReportCardsProps) {
</CardContent> </CardContent>
</Card> </Card>
); );
})} }
interface SectionProps {
title: string;
categories: CategoryReportItem[];
periods: string[];
colorIndexOffset: number;
total: number;
}
function Section({
title,
categories,
periods,
colorIndexOffset,
total,
}: SectionProps) {
if (categories.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</span>
<span className="text-sm text-muted-foreground">
{formatCurrency(total)}
</span>
</div>
{categories.map((category, index) => (
<CategoryCard
key={category.categoryId}
category={category}
periods={periods}
colorIndex={colorIndexOffset + index}
/>
))}
</div>
);
}
export function CategoryReportCards({ data }: CategoryReportCardsProps) {
const { categories, periods } = data;
// Separate categories by type and calculate totals
const { receitas, despesas, receitasTotal, despesasTotal } = useMemo(() => {
const receitas: CategoryReportItem[] = [];
const despesas: CategoryReportItem[] = [];
let receitasTotal = 0;
let despesasTotal = 0;
for (const category of categories) {
if (category.type === "receita") {
receitas.push(category);
receitasTotal += category.total;
} else {
despesas.push(category);
despesasTotal += category.total;
}
}
return { receitas, despesas, receitasTotal, despesasTotal };
}, [categories]);
return (
<div className="md:hidden space-y-6">
{/* Despesas Section */}
<Section
title="Despesas"
categories={despesas}
periods={periods}
colorIndexOffset={0}
total={despesasTotal}
/>
{/* Receitas Section */}
<Section
title="Receitas"
categories={receitas}
periods={periods}
colorIndexOffset={despesas.length}
total={receitasTotal}
/>
</div> </div>
); );
} }

View File

@@ -1,110 +1,49 @@
"use client"; "use client";
import { import { useMemo } from "react";
Table, import type { CategoryReportData, CategoryReportItem } from "@/lib/relatorios/types";
TableBody, import { CategoryTable } from "./category-table";
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { CategoryReportData } from "@/lib/relatorios/types";
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
import { getIconComponent } from "@/lib/utils/icons";
import DotIcon from "../dot-icon";
import { Card } from "../ui/card";
import { CategoryCell } from "./category-cell";
interface CategoryReportTableProps { interface CategoryReportTableProps {
data: CategoryReportData; data: CategoryReportData;
} }
export function CategoryReportTable({ data }: CategoryReportTableProps) { export function CategoryReportTable({ data }: CategoryReportTableProps) {
const { categories, periods, totals, grandTotal } = data; const { categories, periods } = data;
// Separate categories by type
const { receitas, despesas } = useMemo(() => {
const receitas: CategoryReportItem[] = [];
const despesas: CategoryReportItem[] = [];
for (const category of categories) {
if (category.type === "receita") {
receitas.push(category);
} else {
despesas.push(category);
}
}
return { receitas, despesas };
}, [categories]);
return ( return (
<Card className="px-6 py-4"> <div className="flex flex-col gap-6">
<Table> {/* Despesas Table */}
<TableHeader> <CategoryTable
<TableRow> title="Despesas"
<TableHead className="w-[280px] min-w-[280px] font-bold"> categories={despesas}
Categoria periods={periods}
</TableHead> colorIndexOffset={0}
{periods.map((period) => (
<TableHead
key={period}
className="text-right min-w-[120px] font-bold"
>
{formatPeriodLabel(period)}
</TableHead>
))}
<TableHead className="text-right min-w-[120px] font-bold">
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((category) => {
const Icon = category.icon ? getIconComponent(category.icon) : null;
const isReceita = category.type.toLowerCase() === "receita";
const dotColor = isReceita
? "bg-green-600 dark:bg-green-400"
: "bg-red-600 dark:bg-red-400";
return (
<TableRow key={category.categoryId}>
<TableCell>
<div className="flex items-center gap-2">
<DotIcon bg_dot={dotColor} />
{Icon && <Icon className="h-4 w-4 shrink-0" />}
<span className="font-bold truncate">{category.name}</span>
</div>
</TableCell>
{periods.map((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const isFirstMonth = periodIndex === 0;
return (
<TableCell key={period} className="text-right">
<CategoryCell
value={monthData?.amount ?? 0}
previousValue={monthData?.previousAmount ?? 0}
categoryType={category.type}
isFirstMonth={isFirstMonth}
/> />
</TableCell>
);
})}
<TableCell className="text-right font-semibold">
{formatCurrency(category.total)}
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter> {/* Receitas Table */}
<TableRow> <CategoryTable
<TableCell className="min-h-[2.5rem]">Total Geral</TableCell> title="Receitas"
{periods.map((period) => { categories={receitas}
const periodTotal = totals.get(period) ?? 0; periods={periods}
return ( colorIndexOffset={despesas.length}
<TableCell />
key={period} </div>
className="text-right font-semibold min-h-8"
>
{formatCurrency(periodTotal)}
</TableCell>
);
})}
<TableCell className="text-right font-semibold min-h-8">
{formatCurrency(grandTotal)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
); );
} }

View File

@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { CategoryReportItem } from "@/lib/relatorios/types";
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
import { formatPeriodForUrl } from "@/lib/utils/period";
import DotIcon from "../dot-icon";
import { Card } from "../ui/card";
import { CategoryCell } from "./category-cell";
export interface CategoryTableProps {
title: string;
categories: CategoryReportItem[];
periods: string[];
colorIndexOffset: number;
}
export function CategoryTable({
title,
categories,
periods,
colorIndexOffset,
}: CategoryTableProps) {
// Calculate section totals
const sectionTotals = useMemo(() => {
const totalsMap = new Map<string, number>();
let grandTotal = 0;
for (const category of categories) {
grandTotal += category.total;
for (const period of periods) {
const monthData = category.monthlyData.get(period);
const current = totalsMap.get(period) ?? 0;
totalsMap.set(period, current + (monthData?.amount ?? 0));
}
}
return { totalsMap, grandTotal };
}, [categories, periods]);
if (categories.length === 0) {
return null;
}
return (
<Card className="px-6 py-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[280px] min-w-[280px] font-bold">
Categoria
</TableHead>
{periods.map((period) => (
<TableHead
key={period}
className="text-right min-w-[120px] font-bold"
>
{formatPeriodLabel(period)}
</TableHead>
))}
<TableHead className="text-right min-w-[120px] font-bold">
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((category, index) => {
const colorIndex = colorIndexOffset + index;
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
return (
<TableRow key={category.categoryId}>
<TableCell>
<div className="flex items-center gap-2">
<DotIcon
color={
category.type === "receita"
? "bg-green-600"
: "bg-red-600"
}
/>
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={colorIndex}
/>
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex items-center gap-1.5 truncate hover:underline underline-offset-2"
>
{category.name}
</Link>
</div>
</TableCell>
{periods.map((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const isFirstMonth = periodIndex === 0;
return (
<TableCell key={period} className="text-right p-0">
<CategoryCell
value={monthData?.amount ?? 0}
previousValue={monthData?.previousAmount ?? 0}
categoryType={category.type}
isFirstMonth={isFirstMonth}
/>
</TableCell>
);
})}
<TableCell className="text-right font-semibold">
{formatCurrency(category.total)}
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-bold">Total</TableCell>
{periods.map((period) => {
const periodTotal = sectionTotals.totalsMap.get(period) ?? 0;
return (
<TableCell key={period} className="text-right font-semibold">
{formatCurrency(periodTotal)}
</TableCell>
);
})}
<TableCell className="text-right font-semibold">
{formatCurrency(sectionTotals.grandTotal)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
);
}

View File

@@ -5,6 +5,7 @@ export { CategoryReportExport } from "./category-report-export";
export { CategoryReportFilters } from "./category-report-filters"; export { CategoryReportFilters } from "./category-report-filters";
export { CategoryReportPage } from "./category-report-page"; export { CategoryReportPage } from "./category-report-page";
export { CategoryReportTable } from "./category-report-table"; export { CategoryReportTable } from "./category-report-table";
export { CategoryTable } from "./category-table";
export type { export type {
CategoryOption, CategoryOption,
CategoryReportFiltersProps, CategoryReportFiltersProps,

View File

@@ -184,12 +184,12 @@ export function createSidebarNavData(
}, },
{ {
title: "Tendências", title: "Tendências",
url: "/relatorios/categorias", url: "/relatorios/tendencias",
icon: RiFileChartLine, icon: RiFileChartLine,
}, },
{ {
title: "Uso de Cartões", title: "Uso de Cartões",
url: "/relatorios/cartoes", url: "/relatorios/uso-cartoes",
icon: RiBankCard2Line, icon: RiBankCard2Line,
}, },
], ],

View File

@@ -1,16 +1,11 @@
"use client"; "use client";
import { RiPriceTag3Line } from "@remixicon/react"; import { RiPriceTag3Line } from "@remixicon/react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { WidgetEmptyState } from "@/components/widget-empty-state"; import { WidgetEmptyState } from "@/components/widget-empty-state";
import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data"; import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/lib/utils/category-colors";
import { getIconComponent } from "@/lib/utils/icons";
import { title_font } from "@/public/fonts/font_index"; import { title_font } from "@/public/fonts/font_index";
import { Progress } from "../ui/progress"; import { Progress } from "../ui/progress";
@@ -56,12 +51,6 @@ export function TopCategories({ categories }: TopCategoriesProps) {
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="flex flex-col"> <div className="flex flex-col">
{categories.map((category, index) => { {categories.map((category, index) => {
const IconComponent = category.icon
? getIconComponent(category.icon)
: null;
const color = getCategoryColor(index);
const bgColor = getCategoryBgColor(index);
const initials = buildCategoryInitials(category.name);
const percent = const percent =
totalAmount > 0 ? (category.totalAmount / totalAmount) * 100 : 0; totalAmount > 0 ? (category.totalAmount / totalAmount) * 100 : 0;
@@ -72,21 +61,11 @@ export function TopCategories({ categories }: TopCategoriesProps) {
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<div <CategoryIconBadge
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg" icon={category.icon}
style={{ backgroundColor: bgColor }} name={category.name}
> colorIndex={index}
{IconComponent ? ( />
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
{/* Name and percentage */} {/* Name and percentage */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -110,7 +89,7 @@ export function TopCategories({ categories }: TopCategoriesProps) {
</div> </div>
{/* Progress bar */} {/* Progress bar */}
<div className="ml-12 mt-1.5"> <div className="ml-11 mt-1.5">
<Progress className="h-1.5" value={percent} /> <Progress className="h-1.5" value={percent} />
</div> </div>
</div> </div>