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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
78
components/categorias/category-icon-badge.tsx
Normal file
78
components/categorias/category-icon-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user