feat(relatorios): refina indicadores e filtros

This commit is contained in:
Felipe Coutinho
2026-05-28 10:59:36 -03:00
parent 0171b0ce2f
commit 60b2612e8a
11 changed files with 168 additions and 101 deletions

View File

@@ -5,6 +5,7 @@ import { Card } from "@/shared/components/ui/card";
import type { CategoryType } from "@/shared/lib/categories/constants"; import type { CategoryType } from "@/shared/lib/categories/constants";
import { currencyFormatter } from "@/shared/utils/currency"; import { currencyFormatter } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
type CategorySummary = { type CategorySummary = {
id: string; id: string;
@@ -32,19 +33,40 @@ export function CategoryDetailHeader({
percentageChange, percentageChange,
transactionCount, transactionCount,
}: CategoryDetailHeaderProps) { }: CategoryDetailHeaderProps) {
const absoluteChange = currentTotal - previousTotal;
const variationLabel = const variationLabel =
typeof percentageChange === "number" typeof percentageChange === "number"
? formatPercentage(percentageChange, { ? formatPercentage(percentageChange, {
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 1, maximumFractionDigits: 1,
absolute: true, absolute: true,
signDisplay: percentageChange === 0 ? "auto" : "always",
}) })
: "—"; : "—";
const hasComparison = typeof percentageChange === "number";
const isFlat = absoluteChange === 0;
const changeDirection =
absoluteChange > 0 ? "increase" : absoluteChange < 0 ? "decrease" : "flat";
const comparisonTone =
isFlat || !hasComparison
? "neutral"
: category.type === "receita"
? changeDirection === "increase"
? "positive"
: "negative"
: changeDirection === "decrease"
? "positive"
: "negative";
const statusLabel = !hasComparison
? "Sem comparação"
: isFlat
? "Estável"
: changeDirection === "increase"
? "Aumento"
: "Queda";
return ( return (
<Card className="px-4"> <Card className="px-5 py-5">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CategoryIconBadge <CategoryIconBadge
icon={category.icon} icon={category.icon}
@@ -59,41 +81,59 @@ export function CategoryDetailHeader({
<TransactionTypeBadge kind={category.type} /> <TransactionTypeBadge kind={category.type} />
<span> <span>
{transactionCount}{" "} {transactionCount}{" "}
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "} {transactionCount === 1 ? "lançamento" : "lançamentos"} em{" "}
período {currentPeriodLabel}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<div> <div className="rounded-md border border-dashed bg-muted/20 px-3 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel} Total em {currentPeriodLabel}
</p> </p>
<p className="mt-1 text-2xl font-semibold"> <p className="mt-1 text-3xl font-semibold tracking-tight">
{currencyFormatter.format(currentTotal)} {currencyFormatter.format(currentTotal)}
</p> </p>
</div> </div>
<div>
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel} Total em {previousPeriodLabel}
</p> </p>
<p className="mt-1 text-lg font-semibold text-muted-foreground"> <p className="mt-1 text-2xl font-semibold tracking-tight text-muted-foreground">
{currencyFormatter.format(previousTotal)} {currencyFormatter.format(previousTotal)}
</p> </p>
</div> </div>
<div>
<div className="rounded-md border border-dashed bg-muted/20 px-3 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior Variação
</p> </p>
<PercentageChangeIndicator <div className="mt-2 flex flex-wrap items-center gap-2">
value={percentageChange} <span
label={variationLabel} className={cn(
positiveTrend={category.type === "receita" ? "up" : "down"} "inline-flex h-6 items-center rounded-sm border px-2 text-xs font-medium",
className="mt-1 gap-1 text-lg font-semibold" comparisonTone === "positive" &&
iconClassName="size-4" "border-success/30 bg-success/5 text-success",
/> comparisonTone === "negative" &&
"border-destructive/30 bg-destructive/5 text-destructive",
comparisonTone === "neutral" &&
"border-muted-foreground/30 bg-muted/30 text-muted-foreground",
)}
>
{statusLabel}
</span>
<PercentageChangeIndicator
value={percentageChange}
label={variationLabel}
positiveTrend={category.type === "receita" ? "up" : "down"}
className="gap-1 text-lg font-semibold"
iconClassName="size-4"
showFlatIcon
/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { RiExternalLinkLine, RiWallet3Line } from "@remixicon/react"; import { RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers"; import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
@@ -63,7 +63,7 @@ export function CategoryBreakdownListItem({
/> />
</Link> </Link>
</div> </div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
<span> <span>
{formatPercentage( {formatPercentage(
category.percentageOfTotal, category.percentageOfTotal,
@@ -77,10 +77,9 @@ export function CategoryBreakdownListItem({
<span <span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`} className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
> >
<RiWallet3Line className="size-3 shrink-0" />
{budgetExceeded ? ( {budgetExceeded ? (
<> <>
excedeu{" "} Excedeu{" "}
<span className="font-medium"> <span className="font-medium">
{formatCurrency(exceededAmount)} {formatCurrency(exceededAmount)}
</span> </span>

View File

@@ -20,6 +20,7 @@ export function InstallmentExpenseListItem({
const { const {
compactLabel, compactLabel,
isLast, isLast,
remainingLabel,
remainingInstallments, remainingInstallments,
remainingAmount, remainingAmount,
endDate, endDate,
@@ -65,15 +66,22 @@ export function InstallmentExpenseListItem({
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> {remainingInstallments === 0 ? (
{endDate ? `Termina em ${endDate}` : null} <p className="text-xs text-muted-foreground">
{" · Restante "} {endDate ? `Termina em ${endDate}` : null}
<MoneyValues {" · Quitado"}
amount={remainingAmount} </p>
className="inline-block font-semibold" ) : (
/>{" "} <p className="text-xs text-muted-foreground">
({remainingInstallments}) {endDate ? `Termina em ${endDate}` : null}
</p> {` · ${remainingLabel}: `}
<MoneyValues
amount={remainingAmount}
className="inline-block font-semibold"
/>{" "}
({remainingInstallments}x)
</p>
)}
<Progress value={progress} className="mt-1 h-2" /> <Progress value={progress} className="mt-1 h-2" />
</div> </div>

View File

@@ -2,10 +2,10 @@
import { RiInformationLine } from "@remixicon/react"; import { RiInformationLine } from "@remixicon/react";
import { import {
HoverCard, Tooltip,
HoverCardContent, TooltipContent,
HoverCardTrigger, TooltipTrigger,
} from "@/shared/components/ui/hover-card"; } from "@/shared/components/ui/tooltip";
type MetricsCardInfoButtonProps = { type MetricsCardInfoButtonProps = {
label: string; label: string;
@@ -19,8 +19,8 @@ export function MetricsCardInfoButton({
helpLines, helpLines,
}: MetricsCardInfoButtonProps) { }: MetricsCardInfoButtonProps) {
return ( return (
<HoverCard openDelay={150}> <Tooltip>
<HoverCardTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex items-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" className="inline-flex items-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
@@ -28,17 +28,22 @@ export function MetricsCardInfoButton({
> >
<RiInformationLine className="size-4" aria-hidden /> <RiInformationLine className="size-4" aria-hidden />
</button> </button>
</HoverCardTrigger> </TooltipTrigger>
<HoverCardContent align="start" className="w-80 space-y-3"> <TooltipContent
align="start"
side="bottom"
sideOffset={8}
className="max-w-80 space-y-3 p-3 text-left"
>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-foreground">{helpTitle}</p> <p className="text-sm font-medium text-background">{helpTitle}</p>
</div> </div>
<ul className="space-y-2 text-xs text-muted-foreground"> <ul className="space-y-2 text-xs text-background/80">
{helpLines.map((line) => ( {helpLines.map((line) => (
<li key={`${label}-${line}`}>{line}</li> <li key={`${label}-${line}`}>{line}</li>
))} ))}
</ul> </ul>
</HoverCardContent> </TooltipContent>
</HoverCard> </Tooltip>
); );
} }

View File

@@ -73,6 +73,7 @@ export function WidgetSettingsDialog({
</div> </div>
</div> </div>
<Switch <Switch
aria-label={`${isVisible ? "Ocultar" : "Exibir"} widget ${widget.title}`}
checked={isVisible} checked={isVisible}
onCheckedChange={() => onToggleWidget(widget.id)} onCheckedChange={() => onToggleWidget(widget.id)}
/> />

View File

@@ -1,12 +1,11 @@
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries"; import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
import { import { calculateLastInstallmentDate } from "@/shared/lib/installments/utils";
calculateLastInstallmentDate, import { capitalize } from "@/shared/utils/string";
formatLastInstallmentDate,
} from "@/shared/lib/installments/utils";
type InstallmentExpenseDisplay = { type InstallmentExpenseDisplay = {
compactLabel: string | null; compactLabel: string | null;
isLast: boolean; isLast: boolean;
remainingLabel: "Próx." | "Aberto";
remainingInstallments: number; remainingInstallments: number;
remainingAmount: number; remainingAmount: number;
endDate: string | null; endDate: string | null;
@@ -38,21 +37,30 @@ const isInstallmentLast = (
const calculateInstallmentRemainingCount = ( const calculateInstallmentRemainingCount = (
currentInstallment: number | null, currentInstallment: number | null,
installmentCount: number | null, installmentCount: number | null,
isSettled: boolean | null,
) => { ) => {
if (!currentInstallment || !installmentCount) { if (!currentInstallment || !installmentCount) {
return 0; return 0;
} }
return Math.max(0, installmentCount - currentInstallment); const includeCurrentInstallment = isSettled !== true;
const currentOffset = includeCurrentInstallment ? 1 : 0;
return Math.max(0, installmentCount - currentInstallment + currentOffset);
}; };
const calculateInstallmentRemainingAmount = ( const calculateInstallmentRemainingAmount = (
amount: number, amount: number,
currentInstallment: number | null, currentInstallment: number | null,
installmentCount: number | null, installmentCount: number | null,
isSettled: boolean | null,
) => ) =>
amount * amount *
calculateInstallmentRemainingCount(currentInstallment, installmentCount); calculateInstallmentRemainingCount(
currentInstallment,
installmentCount,
isSettled,
);
const formatInstallmentEndDate = ( const formatInstallmentEndDate = (
period: string, period: string,
@@ -69,7 +77,12 @@ const formatInstallmentEndDate = (
installmentCount, installmentCount,
); );
return formatLastInstallmentDate(lastDate); const month = new Intl.DateTimeFormat("pt-BR", {
month: "short",
timeZone: "UTC",
}).format(lastDate);
return `${capitalize(month)} de ${lastDate.getFullYear()}`;
}; };
const buildInstallmentProgress = ( const buildInstallmentProgress = (
@@ -89,7 +102,8 @@ const buildInstallmentProgress = (
export const buildInstallmentExpenseDisplay = ( export const buildInstallmentExpenseDisplay = (
expense: InstallmentExpense, expense: InstallmentExpense,
): InstallmentExpenseDisplay => { ): InstallmentExpenseDisplay => {
const { amount, currentInstallment, installmentCount, period } = expense; const { amount, currentInstallment, installmentCount, isSettled, period } =
expense;
return { return {
compactLabel: buildInstallmentCompactLabel( compactLabel: buildInstallmentCompactLabel(
@@ -97,14 +111,17 @@ export const buildInstallmentExpenseDisplay = (
installmentCount, installmentCount,
), ),
isLast: isInstallmentLast(currentInstallment, installmentCount), isLast: isInstallmentLast(currentInstallment, installmentCount),
remainingLabel: isSettled === true ? "Próx." : "Aberto",
remainingInstallments: calculateInstallmentRemainingCount( remainingInstallments: calculateInstallmentRemainingCount(
currentInstallment, currentInstallment,
installmentCount, installmentCount,
isSettled,
), ),
remainingAmount: calculateInstallmentRemainingAmount( remainingAmount: calculateInstallmentRemainingAmount(
amount, amount,
currentInstallment, currentInstallment,
installmentCount, installmentCount,
isSettled,
), ),
endDate: formatInstallmentEndDate( endDate: formatInstallmentEndDate(
period, period,

View File

@@ -8,6 +8,7 @@ export type InstallmentExpense = {
dueDate: Date | null; dueDate: Date | null;
purchaseDate: Date; purchaseDate: Date;
period: string; period: string;
isSettled: boolean | null;
}; };
export type InstallmentExpensesData = { export type InstallmentExpensesData = {

View File

@@ -405,6 +405,7 @@ const buildInstallmentExpensesData = (
dueDate: row.dueDate, dueDate: row.dueDate,
purchaseDate: row.purchaseDate, purchaseDate: row.purchaseDate,
period: row.period, period: row.period,
isSettled: row.isSettled,
})) }))
.sort((a, b) => { .sort((a, b) => {
const remainingA = const remainingA =

View File

@@ -31,8 +31,12 @@ import {
getCurrentPeriod, getCurrentPeriod,
periodToDate, periodToDate,
} from "@/shared/utils/period"; } from "@/shared/utils/period";
import { slugify } from "@/shared/utils/string";
import type { CategoryReportFiltersProps } from "./types"; import type { CategoryReportFiltersProps } from "./types";
const getCategorySearchValue = (name: string, id: string) =>
`${name} ${slugify(name)} ${id}`;
/** /**
* Category Report Filters Component * Category Report Filters Component
* Provides filters for categories selection and date range * Provides filters for categories selection and date range
@@ -53,7 +57,14 @@ export function CategoryReportFilters({
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
if (!searchValue) return categories; if (!searchValue) return categories;
const search = searchValue.toLowerCase(); const search = searchValue.toLowerCase();
return categories.filter((cat) => cat.name.toLowerCase().includes(search)); const normalizedSearch = slugify(searchValue);
return categories.filter((cat) => {
const categorySearchValue = getCategorySearchValue(cat.name, cat.id);
return (
categorySearchValue.toLowerCase().includes(search) ||
categorySearchValue.includes(normalizedSearch)
);
});
}, [categories, searchValue]); }, [categories, searchValue]);
// Get selected categories for display // Get selected categories for display
@@ -76,15 +87,6 @@ export function CategoryReportFilters({
}); });
}; };
// Handle select all
const handleSelectAll = () => {
onFiltersChange({
...filters,
selectedCategories: categories.map((cat) => cat.id),
});
setOpen(false);
};
// Handle clear all // Handle clear all
const handleClearAll = () => { const handleClearAll = () => {
onFiltersChange({ onFiltersChange({
@@ -130,11 +132,9 @@ export function CategoryReportFilters({
const selectedText = const selectedText =
selectedCategories.length === 0 selectedCategories.length === 0
? "Categoria" ? "Categoria"
: selectedCategories.length === categories.length : selectedCategories.length === 1
? "Todas" ? selectedCategories[0].name
: selectedCategories.length === 1 : `${selectedCategories.length} selecionadas`;
? selectedCategories[0].name
: `${selectedCategories.length} selecionadas`;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -168,25 +168,18 @@ export function CategoryReportFilters({
<CommandList> <CommandList>
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty> <CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
<CommandGroup> <CommandGroup>
{/* Select All / Clear All */} {filters.selectedCategories.length > 0 ? (
<div className="flex gap-1 p-2 border-b"> <div className="p-2 border-b">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 text-xs flex-1" className="h-7 w-full text-xs"
onClick={handleSelectAll} onClick={handleClearAll}
> >
Todas Limpar seleção
</Button> </Button>
<Button </div>
variant="ghost" ) : null}
size="sm"
className="h-7 text-xs flex-1"
onClick={handleClearAll}
>
Limpar
</Button>
</div>
{/* Category List */} {/* Category List */}
{filteredCategories.map((category) => { {filteredCategories.map((category) => {
@@ -200,7 +193,10 @@ export function CategoryReportFilters({
return ( return (
<CommandItem <CommandItem
key={category.id} key={category.id}
value={category.id} value={getCategorySearchValue(
category.name,
category.id,
)}
onSelect={() => handleCategoryToggle(category.id)} onSelect={() => handleCategoryToggle(category.id)}
className="cursor-pointer" className="cursor-pointer"
> >

View File

@@ -5,7 +5,6 @@ import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { formatPeriodLabel } from "@/features/reports/lib/utils"; import { formatPeriodLabel } from "@/features/reports/lib/utils";
import { CategoryIconBadge } from "@/shared/components/entity-avatar"; import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import StatusDot from "@/shared/components/feedback/status-dot";
import { Card } from "@/shared/components/ui/card"; import { Card } from "@/shared/components/ui/card";
import { import {
Table, Table,
@@ -37,6 +36,13 @@ export function CategoryTable({
categories, categories,
periods, periods,
}: CategoryTableProps) { }: CategoryTableProps) {
const categoryColumnLabel =
title === "Despesas"
? "Categoria Despesa"
: title === "Receitas"
? "Categoria Receita"
: "Categoria";
// Calculate section totals // Calculate section totals
const sectionTotals = useMemo(() => { const sectionTotals = useMemo(() => {
const totalsMap = new Map<string, number>(); const totalsMap = new Map<string, number>();
@@ -73,7 +79,7 @@ export function CategoryTable({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[240px] min-w-[240px] font-medium"> <TableHead className="w-[240px] min-w-[240px] font-medium">
Categoria {categoryColumnLabel}
</TableHead> </TableHead>
{periods.map((period) => ( {periods.map((period) => (
<TableHead <TableHead
@@ -114,14 +120,6 @@ export function CategoryTable({
<TableRow key={category.categoryId}> <TableRow key={category.categoryId}>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusDot
color={
category.type === "receita"
? "bg-success"
: "bg-destructive"
}
/>
<CategoryIconBadge <CategoryIconBadge
icon={category.icon} icon={category.icon}
name={category.name} name={category.name}

View File

@@ -66,14 +66,15 @@ export function ExpandableWidgetCard({
contentClassName={EXPANDABLE_CONTENT_CLASSNAME} contentClassName={EXPANDABLE_CONTENT_CLASSNAME}
overlay={ overlay={
hasOverflow ? ( hasOverflow ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-12 pb-6"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-8 pb-4">
<Button <Button
variant="outline" variant="secondary"
size="sm"
className="pointer-events-auto text-xs" className="pointer-events-auto text-xs"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
aria-label="Expandir para ver todo o conteúdo" aria-label="Expandir para ver todo o conteúdo"
> >
Ver tudo{" "} Expandir
<RiExpandDiagonalLine className="size-3" aria-hidden="true" /> <RiExpandDiagonalLine className="size-3" aria-hidden="true" />
</Button> </Button>
</div> </div>
@@ -94,7 +95,7 @@ export function ExpandableWidgetCard({
<p className="text-muted-foreground text-sm">{subtitle}</p> <p className="text-muted-foreground text-sm">{subtitle}</p>
) : null} ) : null}
</DialogHeader> </DialogHeader>
<div className="scrollbar-hide max-h-[calc(85vh-6rem)] overflow-y-auto pb-6"> <div className="-mr-3 max-h-[calc(85vh-6rem)] overflow-y-auto pb-6 pr-3 [scrollbar-gutter:stable]">
{children} {children}
</div> </div>
</DialogContent> </DialogContent>