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

@@ -1,17 +1,12 @@
"use client";
import { RiPieChartLine } from "@remixicon/react";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { WidgetEmptyState } from "@/components/widget-empty-state";
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";
type CardCategoryBreakdownProps = {
@@ -56,64 +51,45 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
<CardContent className="pt-0">
<div className="flex flex-col">
{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);
{data.map((category, index) => (
<div
key={category.id}
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 min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={index}
/>
return (
<div
key={category.id}
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 min-w-0 flex-1 items-center gap-2">
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className="size-4" style={{ color }} />
) : (
<span
className="text-xs font-semibold uppercase"
style={{ color }}
>
{initials}
</span>
)}
</div>
{/* Name and percentage */}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">
{category.name}
</span>
<span className="text-xs text-muted-foreground">
{category.percent.toFixed(0)}% do total
</span>
</div>
</div>
{/* Value */}
<div className="flex shrink-0 flex-col items-end">
<MoneyValues
className="text-foreground"
amount={category.amount}
/>
{/* Name and percentage */}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium truncate block">
{category.name}
</span>
<span className="text-xs text-muted-foreground">
{category.percent.toFixed(0)}% do total
</span>
</div>
</div>
{/* Progress bar */}
<div className="ml-12 mt-1.5">
<Progress className="h-1.5" value={category.percent} />
{/* Value */}
<div className="flex shrink-0 flex-col items-end">
<MoneyValues
className="text-foreground"
amount={category.amount}
/>
</div>
</div>
);
})}
{/* Progress bar */}
<div className="ml-11 mt-1.5">
<Progress className="h-1.5" value={category.percent} />
</div>
</div>
))}
</div>
</CardContent>
</Card>

View File

@@ -60,7 +60,7 @@ export function CardTopExpenses({ data }: CardTopExpensesProps) {
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
{/* 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">
{index + 1}
</span>

View File

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

View File

@@ -1,6 +1,11 @@
"use client";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { formatCurrency, formatPercentageChange } from "@/lib/relatorios/utils";
import { cn } from "@/lib/utils/ui";
@@ -22,25 +27,55 @@ export function CategoryCell({
? ((value - previousValue) / previousValue) * 100
: null;
const absoluteChange = !isFirstMonth ? value - previousValue : null;
const isIncrease = percentageChange !== null && percentageChange > 0;
const isDecrease = percentageChange !== null && percentageChange < 0;
return (
<div className="flex flex-col items-end gap-0.5 min-h-9">
<span className="font-medium">{formatCurrency(value)}</span>
{!isFirstMonth && percentageChange !== null && (
<div
className={cn(
"flex items-center gap-0.5 text-xs",
isIncrease && "text-red-600 dark:text-red-400",
isDecrease && "text-green-600 dark:text-green-400",
<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>
{!isFirstMonth && percentageChange !== null && (
<div
className={cn(
"flex items-center gap-0.5 text-xs",
isIncrease && "text-red-600 dark:text-red-400",
isDecrease && "text-green-600 dark:text-green-400",
)}
>
{isIncrease && <RiArrowUpLine className="h-3 w-3" />}
{isDecrease && <RiArrowDownLine className="h-3 w-3" />}
<span>{formatPercentageChange(percentageChange)}</span>
</div>
)}
>
{isIncrease && <RiArrowUpLine className="h-3 w-3" />}
{isDecrease && <RiArrowDownLine className="h-3 w-3" />}
<span>{formatPercentageChange(percentageChange)}</span>
</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,63 +1,161 @@
"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 type { CategoryReportData } from "@/lib/relatorios/types";
import type {
CategoryReportData,
CategoryReportItem,
} from "@/lib/relatorios/types";
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
import { getIconComponent } from "@/lib/utils/icons";
import { formatPeriodForUrl } from "@/lib/utils/period";
import { CategoryCell } from "./category-cell";
interface CategoryReportCardsProps {
data: CategoryReportData;
}
export function CategoryReportCards({ data }: CategoryReportCardsProps) {
const { categories, periods } = data;
interface CategoryCardProps {
category: CategoryReportItem;
periods: string[];
colorIndex: number;
}
function CategoryCard({ category, periods, colorIndex }: CategoryCardProps) {
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
return (
<div className="md:hidden space-y-4">
{categories.map((category) => {
const Icon = category.icon ? getIconComponent(category.icon) : null;
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={colorIndex}
/>
<Link
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
className="flex-1 truncate hover:underline underline-offset-2"
>
{category.name}
</Link>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{periods.map((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const isFirstMonth = periodIndex === 0;
return (
<Card key={category.categoryId}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{Icon && <Icon className="h-5 w-5 shrink-0" />}
<span className="flex-1 truncate">{category.name}</span>
<TypeBadge type={category.type} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{periods.map((period, periodIndex) => {
const monthData = category.monthlyData.get(period);
const isFirstMonth = periodIndex === 0;
return (
<div
key={period}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<span className="text-sm text-muted-foreground">
{formatPeriodLabel(period)}
</span>
<CategoryCell
value={monthData?.amount ?? 0}
previousValue={monthData?.previousAmount ?? 0}
categoryType={category.type}
isFirstMonth={isFirstMonth}
/>
</div>
);
})}
<div className="flex items-center justify-between pt-2 font-semibold">
<span>Total</span>
<span>{formatCurrency(category.total)}</span>
</div>
</CardContent>
</Card>
);
}
return (
<div
key={period}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<span className="text-sm text-muted-foreground">
{formatPeriodLabel(period)}
</span>
<CategoryCell
value={monthData?.amount ?? 0}
previousValue={monthData?.previousAmount ?? 0}
categoryType={category.type}
isFirstMonth={isFirstMonth}
/>
</div>
);
})}
<div className="flex items-center justify-between pt-2 font-semibold">
<span>Total</span>
<span>{formatCurrency(category.total)}</span>
</div>
</CardContent>
</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>
);
}

View File

@@ -1,110 +1,49 @@
"use client";
import {
Table,
TableBody,
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";
import { useMemo } from "react";
import type { CategoryReportData, CategoryReportItem } from "@/lib/relatorios/types";
import { CategoryTable } from "./category-table";
interface CategoryReportTableProps {
data: CategoryReportData;
}
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 (
<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>
<div className="flex flex-col gap-6">
{/* Despesas Table */}
<CategoryTable
title="Despesas"
categories={despesas}
periods={periods}
colorIndexOffset={0}
/>
<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>
<TableRow>
<TableCell className="min-h-[2.5rem]">Total Geral</TableCell>
{periods.map((period) => {
const periodTotal = totals.get(period) ?? 0;
return (
<TableCell
key={period}
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>
{/* Receitas Table */}
<CategoryTable
title="Receitas"
categories={receitas}
periods={periods}
colorIndexOffset={despesas.length}
/>
</div>
);
}

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 { CategoryReportPage } from "./category-report-page";
export { CategoryReportTable } from "./category-report-table";
export { CategoryTable } from "./category-table";
export type {
CategoryOption,
CategoryReportFiltersProps,