refactor(dashboard): reorganizar módulos em subdiretórios e nova arquitetura de widgets

Arquivos de queries, helpers e controllers dispersos na raiz de dashboard/
foram movidos para subdiretórios temáticos (bills/, invoices/, notes/,
notifications/, overview/, payments/, goals-progress/, categories/).
~25 widgets monolíticos obsoletos removidos em favor de nova arquitetura
baseada em widget-registry com components/widgets/. Novos componentes:
category-breakdown-chart/list, goals-progress-item, percentage-change-indicator.
Imports atualizados em fetch-dashboard-data e transaction-filters limpos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-20 17:51:56 +00:00
parent 3e80d5995b
commit ba05985725
99 changed files with 784 additions and 2055 deletions

View File

@@ -0,0 +1,161 @@
"use client";
import { useMemo } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
const CATEGORY_BREAKDOWN_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
const formatPercentage = (value: number, digits: number) =>
formatPercentageValue(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
absolute: true,
});
type CategoryBreakdownChartProps = {
categories: DashboardCategoryBreakdownItem[];
percentageDigits: number;
};
export function CategoryBreakdownChart({
categories,
percentageDigits,
}: CategoryBreakdownChartProps) {
const chartConfig = useMemo(() => {
const nextConfig: ChartConfig = {};
const topCategories = categories.slice(0, 7);
topCategories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
if (categories.length > 7) {
nextConfig.outros = { label: "Outros", color: "var(--chart-6)" };
}
return nextConfig;
}, [categories]);
const chartData = useMemo(() => {
if (categories.length <= 7) {
return categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
const topCategories = categories.slice(0, 7);
const otherCategories = categories.slice(7);
const otherTotal = otherCategories.reduce(
(sum, c) => sum + c.currentAmount,
0,
);
const otherPercentage = otherCategories.reduce(
(sum, c) => sum + c.percentageOfTotal,
0,
);
const groupedData = topCategories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
if (otherCategories.length > 0) {
groupedData.push({
category: "outros",
name: "Outros",
value: otherTotal,
percentage: otherPercentage,
fill: chartConfig.outros?.color,
});
}
return groupedData;
}, [categories, chartConfig]);
return (
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ payload }) =>
formatPercentage(
(payload as { percentage?: number } | undefined)?.percentage ??
0,
percentageDigits,
)
}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const entry = payload[0]?.payload;
if (!entry) return null;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-xs uppercase text-muted-foreground">
{entry.name}
</span>
<span className="font-medium text-foreground">
{formatCurrency(entry.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(entry.percentage, percentageDigits)}{" "}
do total
</span>
</div>
</div>
</div>
);
}}
/>
</PieChart>
</ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 shrink-0 rounded-sm"
style={{ backgroundColor: entry.fill }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { RiExternalLinkLine, RiWallet3Line } 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";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
type CategoryBreakdownListItemConfig = {
shareLabel: string;
percentageDigits: number;
positiveTrend: "up" | "down";
includeBudgetAmount: boolean;
};
type CategoryBreakdownListItemProps = {
category: DashboardCategoryBreakdownItem;
periodParam: string;
config: CategoryBreakdownListItemConfig;
};
const formatPercentage = (value: number, digits: number) =>
formatPercentageValue(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
absolute: true,
});
export function CategoryBreakdownListItem({
category,
periodParam,
config,
}: CategoryBreakdownListItemProps) {
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
return (
<div>
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>
{formatPercentage(
category.percentageOfTotal,
config.percentageDigits,
)}{" "}
da {config.shareLabel}
</span>
{hasBudget && category.budgetUsedPercentage !== null ? (
<>
<span aria-hidden>·</span>
<span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
>
<RiWallet3Line className="size-3 shrink-0" />
{budgetExceeded ? (
<>
excedeu{" "}
<span className="font-medium">
{formatCurrency(exceededAmount)}
</span>
</>
) : (
<>
{formatPercentage(
category.budgetUsedPercentage,
config.percentageDigits,
)}{" "}
do limite
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
</>
)}
</span>
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground font-medium"
amount={category.currentAmount}
/>
<PercentageChangeIndicator
value={category.percentageChange}
label={
category.percentageChange !== null
? formatPercentage(
category.percentageChange,
config.percentageDigits,
)
: undefined
}
positiveTrend={config.positiveTrend}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { CategoryBreakdownListItem } from "./category-breakdown-list-item";
type CategoryBreakdownListConfig = {
shareLabel: string;
percentageDigits: number;
positiveTrend: "up" | "down";
includeBudgetAmount: boolean;
};
type CategoryBreakdownListProps = {
categories: DashboardCategoryBreakdownItem[];
periodParam: string;
config: CategoryBreakdownListConfig;
};
export function CategoryBreakdownList({
categories,
periodParam,
config,
}: CategoryBreakdownListProps) {
return (
<div>
{categories.map((category) => (
<CategoryBreakdownListItem
key={category.categoryId}
category={category}
periodParam={periodParam}
config={config}
/>
))}
</div>
);
}

View File

@@ -1,21 +1,12 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Pie, PieChart, Tooltip } from "recharts";
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { type ChartConfig, ChartContainer } from "@/shared/components/ui/chart";
import { useState } from "react";
import type { DashboardCategoryBreakdownData } from "@/features/dashboard/categories/category-breakdown-helpers";
import {
Tabs,
TabsContent,
@@ -23,9 +14,9 @@ import {
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatCurrency } from "@/shared/utils/currency";
import { formatPercentage as formatPercentageValue } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { CategoryBreakdownChart } from "./category-breakdown-chart";
import { CategoryBreakdownList } from "./category-breakdown-list";
type CategoryBreakdownVariant = "income" | "expense";
@@ -35,16 +26,6 @@ type CategoryBreakdownWidgetViewProps = {
variant: CategoryBreakdownVariant;
};
const CATEGORY_BREAKDOWN_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
const VARIANT_CONFIG = {
income: {
emptyTitle: "Nenhuma receita encontrada",
@@ -52,10 +33,7 @@ const VARIANT_CONFIG = {
"Quando houver receitas registradas, elas aparecerão aqui.",
shareLabel: "receita total",
percentageDigits: 1,
changeClassName: {
increase: "text-success",
decrease: "text-destructive",
},
positiveTrend: "up",
includeBudgetAmount: true,
},
expense: {
@@ -64,21 +42,11 @@ const VARIANT_CONFIG = {
"Quando houver despesas registradas, elas aparecerão aqui.",
shareLabel: "despesa total",
percentageDigits: 0,
changeClassName: {
increase: "text-destructive",
decrease: "text-success",
},
positiveTrend: "down",
includeBudgetAmount: false,
},
} as const;
const formatPercentage = (value: number, digits: number) =>
formatPercentageValue(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
absolute: true,
});
export function CategoryBreakdownWidgetView({
data,
period,
@@ -88,78 +56,6 @@ export function CategoryBreakdownWidgetView({
const periodParam = formatPeriodForUrl(period);
const config = VARIANT_CONFIG[variant];
const chartConfig = useMemo(() => {
const nextConfig: ChartConfig = {};
if (data.categories.length <= 7) {
data.categories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
} else {
const topCategories = data.categories.slice(0, 7);
topCategories.forEach((category, index) => {
nextConfig[category.categoryId] = {
label: category.categoryName,
color:
CATEGORY_BREAKDOWN_COLORS[index % CATEGORY_BREAKDOWN_COLORS.length],
};
});
nextConfig.outros = {
label: "Outros",
color: "var(--chart-6)",
};
}
return nextConfig;
}, [data.categories]);
const chartData = useMemo(() => {
if (data.categories.length <= 7) {
return data.categories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
}
const topCategories = data.categories.slice(0, 7);
const otherCategories = data.categories.slice(7);
const otherTotal = otherCategories.reduce(
(sum, category) => sum + category.currentAmount,
0,
);
const otherPercentage = otherCategories.reduce(
(sum, category) => sum + category.percentageOfTotal,
0,
);
const groupedData = topCategories.map((category) => ({
category: category.categoryId,
name: category.categoryName,
value: category.currentAmount,
percentage: category.percentageOfTotal,
fill: chartConfig[category.categoryId]?.color,
}));
if (otherCategories.length > 0) {
groupedData.push({
category: "outros",
name: "Outros",
value: otherTotal,
percentage: otherPercentage,
fill: chartConfig.outros?.color,
});
}
return groupedData;
}, [data.categories, chartConfig]);
if (data.categories.length === 0) {
return (
<WidgetEmptyState
@@ -178,11 +74,17 @@ export function CategoryBreakdownWidgetView({
>
<div className="flex items-center justify-between">
<TabsList className="grid grid-cols-2">
<TabsTrigger value="list" className="text-xs">
<TabsTrigger
value="list"
className="text-xs data-[state=active]:bg-transparent"
>
<RiListUnordered className="mr-1 size-3.5" />
Lista
</TabsTrigger>
<TabsTrigger value="chart" className="text-xs">
<TabsTrigger
value="chart"
className="text-xs data-[state=active]:bg-transparent"
>
<RiPieChart2Line className="mr-1 size-3.5" />
Gráfico
</TabsTrigger>
@@ -190,195 +92,18 @@ export function CategoryBreakdownWidgetView({
</div>
<TabsContent value="list" className="mt-0">
<div>
{data.categories.map((category, index) => {
const hasIncrease =
category.percentageChange !== null &&
category.percentageChange > 0;
const hasDecrease =
category.percentageChange !== null &&
category.percentageChange < 0;
const hasBudget = category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
category.budgetUsedPercentage > 100;
const exceededAmount =
budgetExceeded && category.budgetAmount
? category.currentAmount - category.budgetAmount
: 0;
const changeClassName = hasIncrease
? config.changeClassName.increase
: hasDecrease
? config.changeClassName.decrease
: "text-muted-foreground";
return (
<div key={category.categoryId}>
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
>
<span className="truncate">
{category.categoryName}
</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<span>
{formatPercentage(
category.percentageOfTotal,
config.percentageDigits,
)}{" "}
da {config.shareLabel}
</span>
{hasBudget && category.budgetUsedPercentage !== null ? (
<>
<span aria-hidden>·</span>
<span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
>
<RiWallet3Line className="size-3 shrink-0" />
{budgetExceeded ? (
<>
excedeu{" "}
<span className="font-medium">
{formatCurrency(exceededAmount)}
</span>
</>
) : (
<>
{formatPercentage(
category.budgetUsedPercentage,
config.percentageDigits,
)}{" "}
do limite
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
</>
)}
</span>
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground font-medium"
amount={category.currentAmount}
/>
{category.percentageChange !== null ? (
<span
className={`flex items-center gap-0.5 text-xs font-medium ${changeClassName}`}
>
{hasIncrease ? (
<RiArrowUpSFill className="size-3" />
) : null}
{hasDecrease ? (
<RiArrowDownSFill className="size-3" />
) : null}
{formatPercentage(
category.percentageChange,
config.percentageDigits,
)}
</span>
) : null}
</div>
</div>
</div>
);
})}
</div>
<CategoryBreakdownList
categories={data.categories}
periodParam={periodParam}
config={config}
/>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ payload }) =>
formatPercentage(
(payload as { percentage?: number } | undefined)
?.percentage ?? 0,
config.percentageDigits,
)
}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) {
return null;
}
const entry = payload[0]?.payload;
if (!entry) {
return null;
}
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-xs uppercase text-muted-foreground">
{entry.name}
</span>
<span className="font-medium text-foreground">
{formatCurrency(entry.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(
entry.percentage,
config.percentageDigits,
)}{" "}
do total
</span>
</div>
</div>
</div>
);
}}
/>
</PieChart>
</ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 shrink-0 rounded-sm"
style={{ backgroundColor: entry.fill }}
/>
<span className="truncate text-xs text-muted-foreground">
{entry.name}
</span>
</div>
))}
</div>
</div>
<CategoryBreakdownChart
categories={data.categories}
percentageDigits={config.percentageDigits}
/>
</TabsContent>
</Tabs>
);