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

@@ -130,10 +130,11 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
</div>
) : (
<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
key={category.id}
category={category}
colorIndex={index}
onEdit={handleEdit}
onRemove={handleRemoveRequest}
/>

View File

@@ -1,94 +1,103 @@
"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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TypeBadge } from "../type-badge";
import { CategoryIcon } from "./category-icon";
RiDeleteBin5Line,
RiFileList2Line,
RiPencilLine,
} from "@remixicon/react";
import Link from "next/link";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
import type { Category } from "./types";
interface CategoryCardProps {
category: Category;
colorIndex: number;
onEdit: (category: Category) => void;
onRemove: (category: Category) => void;
}
export function CategoryCard({
category,
colorIndex,
onEdit,
onRemove,
}: CategoryCardProps) {
// Categorias protegidas que não podem ser editadas ou removidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
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 (
<Card className="group py-2">
<CardContent className="p-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<span className="flex size-11 items-center justify-center text-primary">
<CategoryIcon name={category.icon} className="size-6" />
</span>
<div className="space-y-1">
<h3 className="text-base font-medium leading-tight">
<Link
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>
<Card className="flex h-full flex-col gap-0 py-3">
<CardContent className="flex flex-1 flex-col">
<div className="flex items-center gap-3">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={colorIndex}
size="md"
/>
<h3 className="leading-tight">{category.name}</h3>
</div>
</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>
);
}

View File

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