mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(relatorios): refina indicadores e filtros
This commit is contained in:
@@ -5,6 +5,7 @@ import { Card } from "@/shared/components/ui/card";
|
||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
import { currencyFormatter } from "@/shared/utils/currency";
|
||||
import { formatPercentage } from "@/shared/utils/percentage";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type CategorySummary = {
|
||||
id: string;
|
||||
@@ -32,19 +33,40 @@ export function CategoryDetailHeader({
|
||||
percentageChange,
|
||||
transactionCount,
|
||||
}: CategoryDetailHeaderProps) {
|
||||
const absoluteChange = currentTotal - previousTotal;
|
||||
const variationLabel =
|
||||
typeof percentageChange === "number"
|
||||
? formatPercentage(percentageChange, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
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 (
|
||||
<Card className="px-4">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Card className="px-5 py-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<CategoryIconBadge
|
||||
icon={category.icon}
|
||||
@@ -59,41 +81,59 @@ export function CategoryDetailHeader({
|
||||
<TransactionTypeBadge kind={category.type} />
|
||||
<span>
|
||||
{transactionCount}{" "}
|
||||
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
|
||||
período
|
||||
{transactionCount === 1 ? "lançamento" : "lançamentos"} em{" "}
|
||||
{currentPeriodLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
|
||||
<div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<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">
|
||||
Total em {currentPeriodLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
<p className="mt-1 text-3xl font-semibold tracking-tight">
|
||||
{currencyFormatter.format(currentTotal)}
|
||||
</p>
|
||||
</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">
|
||||
Total em {previousPeriodLabel}
|
||||
</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)}
|
||||
</p>
|
||||
</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">
|
||||
Variação vs mês anterior
|
||||
Variação
|
||||
</p>
|
||||
<PercentageChangeIndicator
|
||||
value={percentageChange}
|
||||
label={variationLabel}
|
||||
positiveTrend={category.type === "receita" ? "up" : "down"}
|
||||
className="mt-1 gap-1 text-lg font-semibold"
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center rounded-sm border px-2 text-xs font-medium",
|
||||
comparisonTone === "positive" &&
|
||||
"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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RiExternalLinkLine, RiWallet3Line } from "@remixicon/react";
|
||||
import { RiExternalLinkLine } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
|
||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||
@@ -63,7 +63,7 @@ export function CategoryBreakdownListItem({
|
||||
/>
|
||||
</Link>
|
||||
</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>
|
||||
{formatPercentage(
|
||||
category.percentageOfTotal,
|
||||
@@ -77,10 +77,9 @@ export function CategoryBreakdownListItem({
|
||||
<span
|
||||
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
|
||||
>
|
||||
<RiWallet3Line className="size-3 shrink-0" />
|
||||
{budgetExceeded ? (
|
||||
<>
|
||||
excedeu{" "}
|
||||
Excedeu{" "}
|
||||
<span className="font-medium">
|
||||
{formatCurrency(exceededAmount)}
|
||||
</span>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function InstallmentExpenseListItem({
|
||||
const {
|
||||
compactLabel,
|
||||
isLast,
|
||||
remainingLabel,
|
||||
remainingInstallments,
|
||||
remainingAmount,
|
||||
endDate,
|
||||
@@ -65,15 +66,22 @@ export function InstallmentExpenseListItem({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{" · Restante "}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-semibold"
|
||||
/>{" "}
|
||||
({remainingInstallments})
|
||||
</p>
|
||||
{remainingInstallments === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{" · Quitado"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{endDate ? `Termina em ${endDate}` : null}
|
||||
{` · ${remainingLabel}: `}
|
||||
<MoneyValues
|
||||
amount={remainingAmount}
|
||||
className="inline-block font-semibold"
|
||||
/>{" "}
|
||||
({remainingInstallments}x)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Progress value={progress} className="mt-1 h-2" />
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { RiInformationLine } from "@remixicon/react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/shared/components/ui/hover-card";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
|
||||
type MetricsCardInfoButtonProps = {
|
||||
label: string;
|
||||
@@ -19,8 +19,8 @@ export function MetricsCardInfoButton({
|
||||
helpLines,
|
||||
}: MetricsCardInfoButtonProps) {
|
||||
return (
|
||||
<HoverCard openDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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"
|
||||
@@ -28,17 +28,22 @@ export function MetricsCardInfoButton({
|
||||
>
|
||||
<RiInformationLine className="size-4" aria-hidden />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-80 space-y-3">
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="max-w-80 space-y-3 p-3 text-left"
|
||||
>
|
||||
<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>
|
||||
<ul className="space-y-2 text-xs text-muted-foreground">
|
||||
<ul className="space-y-2 text-xs text-background/80">
|
||||
{helpLines.map((line) => (
|
||||
<li key={`${label}-${line}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function WidgetSettingsDialog({
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={`${isVisible ? "Ocultar" : "Exibir"} widget ${widget.title}`}
|
||||
checked={isVisible}
|
||||
onCheckedChange={() => onToggleWidget(widget.id)}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { InstallmentExpense } from "@/features/dashboard/expenses/installment-expenses-queries";
|
||||
import {
|
||||
calculateLastInstallmentDate,
|
||||
formatLastInstallmentDate,
|
||||
} from "@/shared/lib/installments/utils";
|
||||
import { calculateLastInstallmentDate } from "@/shared/lib/installments/utils";
|
||||
import { capitalize } from "@/shared/utils/string";
|
||||
|
||||
type InstallmentExpenseDisplay = {
|
||||
compactLabel: string | null;
|
||||
isLast: boolean;
|
||||
remainingLabel: "Próx." | "Aberto";
|
||||
remainingInstallments: number;
|
||||
remainingAmount: number;
|
||||
endDate: string | null;
|
||||
@@ -38,21 +37,30 @@ const isInstallmentLast = (
|
||||
const calculateInstallmentRemainingCount = (
|
||||
currentInstallment: number | null,
|
||||
installmentCount: number | null,
|
||||
isSettled: boolean | null,
|
||||
) => {
|
||||
if (!currentInstallment || !installmentCount) {
|
||||
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 = (
|
||||
amount: number,
|
||||
currentInstallment: number | null,
|
||||
installmentCount: number | null,
|
||||
isSettled: boolean | null,
|
||||
) =>
|
||||
amount *
|
||||
calculateInstallmentRemainingCount(currentInstallment, installmentCount);
|
||||
calculateInstallmentRemainingCount(
|
||||
currentInstallment,
|
||||
installmentCount,
|
||||
isSettled,
|
||||
);
|
||||
|
||||
const formatInstallmentEndDate = (
|
||||
period: string,
|
||||
@@ -69,7 +77,12 @@ const formatInstallmentEndDate = (
|
||||
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 = (
|
||||
@@ -89,7 +102,8 @@ const buildInstallmentProgress = (
|
||||
export const buildInstallmentExpenseDisplay = (
|
||||
expense: InstallmentExpense,
|
||||
): InstallmentExpenseDisplay => {
|
||||
const { amount, currentInstallment, installmentCount, period } = expense;
|
||||
const { amount, currentInstallment, installmentCount, isSettled, period } =
|
||||
expense;
|
||||
|
||||
return {
|
||||
compactLabel: buildInstallmentCompactLabel(
|
||||
@@ -97,14 +111,17 @@ export const buildInstallmentExpenseDisplay = (
|
||||
installmentCount,
|
||||
),
|
||||
isLast: isInstallmentLast(currentInstallment, installmentCount),
|
||||
remainingLabel: isSettled === true ? "Próx." : "Aberto",
|
||||
remainingInstallments: calculateInstallmentRemainingCount(
|
||||
currentInstallment,
|
||||
installmentCount,
|
||||
isSettled,
|
||||
),
|
||||
remainingAmount: calculateInstallmentRemainingAmount(
|
||||
amount,
|
||||
currentInstallment,
|
||||
installmentCount,
|
||||
isSettled,
|
||||
),
|
||||
endDate: formatInstallmentEndDate(
|
||||
period,
|
||||
|
||||
@@ -8,6 +8,7 @@ export type InstallmentExpense = {
|
||||
dueDate: Date | null;
|
||||
purchaseDate: Date;
|
||||
period: string;
|
||||
isSettled: boolean | null;
|
||||
};
|
||||
|
||||
export type InstallmentExpensesData = {
|
||||
|
||||
@@ -405,6 +405,7 @@ const buildInstallmentExpensesData = (
|
||||
dueDate: row.dueDate,
|
||||
purchaseDate: row.purchaseDate,
|
||||
period: row.period,
|
||||
isSettled: row.isSettled,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const remainingA =
|
||||
|
||||
@@ -31,8 +31,12 @@ import {
|
||||
getCurrentPeriod,
|
||||
periodToDate,
|
||||
} from "@/shared/utils/period";
|
||||
import { slugify } from "@/shared/utils/string";
|
||||
import type { CategoryReportFiltersProps } from "./types";
|
||||
|
||||
const getCategorySearchValue = (name: string, id: string) =>
|
||||
`${name} ${slugify(name)} ${id}`;
|
||||
|
||||
/**
|
||||
* Category Report Filters Component
|
||||
* Provides filters for categories selection and date range
|
||||
@@ -53,7 +57,14 @@ export function CategoryReportFilters({
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!searchValue) return categories;
|
||||
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]);
|
||||
|
||||
// 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
|
||||
const handleClearAll = () => {
|
||||
onFiltersChange({
|
||||
@@ -130,11 +132,9 @@ export function CategoryReportFilters({
|
||||
const selectedText =
|
||||
selectedCategories.length === 0
|
||||
? "Categoria"
|
||||
: selectedCategories.length === categories.length
|
||||
? "Todas"
|
||||
: selectedCategories.length === 1
|
||||
? selectedCategories[0].name
|
||||
: `${selectedCategories.length} selecionadas`;
|
||||
: selectedCategories.length === 1
|
||||
? selectedCategories[0].name
|
||||
: `${selectedCategories.length} selecionadas`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -168,25 +168,18 @@ export function CategoryReportFilters({
|
||||
<CommandList>
|
||||
<CommandEmpty>Nenhuma categoria encontrada.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* Select All / Clear All */}
|
||||
<div className="flex gap-1 p-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
Todas
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
{filters.selectedCategories.length > 0 ? (
|
||||
<div className="p-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Limpar seleção
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category List */}
|
||||
{filteredCategories.map((category) => {
|
||||
@@ -200,7 +193,10 @@ export function CategoryReportFilters({
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
value={getCategorySearchValue(
|
||||
category.name,
|
||||
category.id,
|
||||
)}
|
||||
onSelect={() => handleCategoryToggle(category.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { formatPeriodLabel } from "@/features/reports/lib/utils";
|
||||
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
|
||||
import StatusDot from "@/shared/components/feedback/status-dot";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
@@ -37,6 +36,13 @@ export function CategoryTable({
|
||||
categories,
|
||||
periods,
|
||||
}: CategoryTableProps) {
|
||||
const categoryColumnLabel =
|
||||
title === "Despesas"
|
||||
? "Categoria Despesa"
|
||||
: title === "Receitas"
|
||||
? "Categoria Receita"
|
||||
: "Categoria";
|
||||
|
||||
// Calculate section totals
|
||||
const sectionTotals = useMemo(() => {
|
||||
const totalsMap = new Map<string, number>();
|
||||
@@ -73,7 +79,7 @@ export function CategoryTable({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[240px] min-w-[240px] font-medium">
|
||||
Categoria
|
||||
{categoryColumnLabel}
|
||||
</TableHead>
|
||||
{periods.map((period) => (
|
||||
<TableHead
|
||||
@@ -114,14 +120,6 @@ export function CategoryTable({
|
||||
<TableRow key={category.categoryId}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot
|
||||
color={
|
||||
category.type === "receita"
|
||||
? "bg-success"
|
||||
: "bg-destructive"
|
||||
}
|
||||
/>
|
||||
|
||||
<CategoryIconBadge
|
||||
icon={category.icon}
|
||||
name={category.name}
|
||||
|
||||
@@ -66,14 +66,15 @@ export function ExpandableWidgetCard({
|
||||
contentClassName={EXPANDABLE_CONTENT_CLASSNAME}
|
||||
overlay={
|
||||
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
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="pointer-events-auto text-xs"
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label="Expandir para ver todo o conteúdo"
|
||||
>
|
||||
Ver tudo{" "}
|
||||
Expandir
|
||||
<RiExpandDiagonalLine className="size-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -94,7 +95,7 @@ export function ExpandableWidgetCard({
|
||||
<p className="text-muted-foreground text-sm">{subtitle}</p>
|
||||
) : null}
|
||||
</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}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user