diff --git a/app/globals.css b/app/globals.css
index 7756c75..638587a 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -3,7 +3,7 @@
@custom-variant dark (&:is(.dark *));
@theme {
- --spacing-custom-height-1: 29rem;
+ --spacing-custom-height-1: 30rem;
}
:root {
@@ -41,11 +41,12 @@
--ring: oklch(69.18% 0.18855 38.353);
/* Charts - harmonious, distinct, accessible */
- --chart-1: oklch(65% 0.18 160);
- --chart-2: oklch(60% 0.2 28);
- --chart-3: oklch(58% 0.19 295);
- --chart-4: oklch(55% 0.2 260);
- --chart-5: oklch(68% 0.16 85);
+ --chart-1: var(--color-emerald-400);
+ --chart-2: var(--color-orange-400);
+ --chart-3: var(--color-indigo-400);
+ --chart-4: var(--color-amber-400);
+ --chart-5: var(--color-pink-400);
+ --chart-6: var(--color-stone-400);
/* Sidebar - slight elevation from background */
--sidebar: oklch(94.637% 0.00925 62.27);
@@ -121,11 +122,12 @@
--ring: oklch(69.18% 0.18855 38.353);
/* Charts - bright and distinct on dark */
- --chart-1: oklch(72% 0.17 158);
- --chart-2: oklch(68% 0.19 30);
- --chart-3: oklch(68% 0.18 298);
- --chart-4: oklch(65% 0.18 262);
- --chart-5: oklch(74% 0.15 88);
+ --chart-1: var(--color-emerald-500);
+ --chart-2: var(--color-orange-500);
+ --chart-3: var(--color-indigo-500);
+ --chart-4: var(--color-amber-500);
+ --chart-5: var(--color-pink-500);
+ --chart-6: var(--color-stone-500);
/* Sidebar - slight separation from main */
--sidebar: oklch(24.039% 0.00151 16.27);
@@ -192,6 +194,7 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
+ --color-chart-6: var(--chart-6);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
diff --git a/components/dashboard/expenses-by-category-widget-with-chart.tsx b/components/dashboard/expenses-by-category-widget-with-chart.tsx
new file mode 100644
index 0000000..9a7a537
--- /dev/null
+++ b/components/dashboard/expenses-by-category-widget-with-chart.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import MoneyValues from "@/components/money-values";
+import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
+import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
+import { getIconComponent } from "@/lib/utils/icons";
+import { formatPeriodForUrl } from "@/lib/utils/period";
+import {
+ RiArrowDownLine,
+ RiArrowUpLine,
+ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
+import { WidgetEmptyState } from "../widget-empty-state";
+
+type ExpensesByCategoryWidgetWithChartProps = {
+ data: ExpensesByCategoryData;
+ period: string;
+};
+
+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";
+};
+
+const formatPercentage = (value: number) => {
+ return `${Math.abs(value).toFixed(0)}%`;
+};
+
+const formatCurrency = (value: number) =>
+ new Intl.NumberFormat("pt-BR", {
+ style: "currency",
+ currency: "BRL",
+ }).format(value);
+
+export function ExpensesByCategoryWidgetWithChart({
+ data,
+ period,
+}: ExpensesByCategoryWidgetWithChartProps) {
+ const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
+ const periodParam = formatPeriodForUrl(period);
+
+ // Configuração do chart com cores do CSS
+ const chartConfig = useMemo(() => {
+ const config: ChartConfig = {};
+ const colors = [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ "var(--chart-3)",
+ "var(--chart-4)",
+ "var(--chart-5)",
+ "var(--chart-1)",
+ "var(--chart-2)",
+ ];
+
+ if (data.categories.length <= 7) {
+ data.categories.forEach((category, index) => {
+ config[category.categoryId] = {
+ label: category.categoryName,
+ color: colors[index % colors.length],
+ };
+ });
+ } else {
+ // Top 7 + Outros
+ const top7 = data.categories.slice(0, 7);
+ top7.forEach((category, index) => {
+ config[category.categoryId] = {
+ label: category.categoryName,
+ color: colors[index % colors.length],
+ };
+ });
+ config["outros"] = {
+ label: "Outros",
+ color: "var(--chart-6)",
+ };
+ }
+
+ return config;
+ }, [data.categories]);
+
+ // Preparar dados para o gráfico de pizza - Top 7 + Outros
+ 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,
+ }));
+ }
+
+ // Pegar top 7 categorias
+ const top7 = data.categories.slice(0, 7);
+ const others = data.categories.slice(7);
+
+ // Somar o restante
+ const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
+ const othersPercentage = others.reduce(
+ (sum, cat) => sum + cat.percentageOfTotal,
+ 0
+ );
+
+ const top7Data = top7.map((category) => ({
+ category: category.categoryId,
+ name: category.categoryName,
+ value: category.currentAmount,
+ percentage: category.percentageOfTotal,
+ fill: chartConfig[category.categoryId]?.color,
+ }));
+
+ // Adicionar "Outros" se houver
+ if (others.length > 0) {
+ top7Data.push({
+ category: "outros",
+ name: "Outros",
+ value: othersTotal,
+ percentage: othersPercentage,
+ fill: chartConfig["outros"]?.color,
+ });
+ }
+
+ return top7Data;
+ }, [data.categories, chartConfig]);
+
+ if (data.categories.length === 0) {
+ return (
+ }
+ title="Nenhuma despesa encontrada"
+ description="Quando houver despesas registradas, elas aparecerão aqui."
+ />
+ );
+ }
+
+ return (
+ setActiveTab(v as "list" | "chart")}
+ className="w-full"
+ >
+
+
+
+
+ Lista
+
+
+
+ Gráfico
+
+
+
+
+
+
+ {data.categories.map((category) => {
+ const IconComponent = category.categoryIcon
+ ? getIconComponent(category.categoryIcon)
+ : null;
+ const initials = buildInitials(category.categoryName);
+ 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;
+
+ return (
+
+
+
+
+ {IconComponent ? (
+
+ ) : (
+
+ {initials}
+
+ )}
+
+
+
+
+
+
+ {category.categoryName}
+
+
+
+
+
+
+ {formatPercentage(category.percentageOfTotal)} da
+ despesa total
+
+
+
+
+
+
+
+ {category.percentageChange !== null && (
+
+ {hasIncrease && }
+ {hasDecrease && }
+ {formatPercentage(category.percentageChange)}
+
+ )}
+
+
+
+ {hasBudget && category.budgetUsedPercentage !== null && (
+
+
+
+ {budgetExceeded ? (
+ <>
+ {formatPercentage(category.budgetUsedPercentage)} do
+ limite - excedeu em {formatCurrency(exceededAmount)}
+ >
+ ) : (
+ <>
+ {formatPercentage(category.budgetUsedPercentage)} do
+ limite
+ >
+ )}
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+ formatPercentage(entry.percentage)}
+ outerRadius={75}
+ dataKey="value"
+ nameKey="category"
+ />
+ {
+ if (active && payload && payload.length) {
+ const data = payload[0].payload;
+ return (
+
+
+
+
+ {data.name}
+
+
+ {formatCurrency(data.value)}
+
+
+ {formatPercentage(data.percentage)} do total
+
+
+
+
+ );
+ }
+ return null;
+ }}
+ />
+
+
+
+
+ {chartData.map((entry, index) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/dashboard/income-by-category-widget-with-chart.tsx b/components/dashboard/income-by-category-widget-with-chart.tsx
new file mode 100644
index 0000000..133fcda
--- /dev/null
+++ b/components/dashboard/income-by-category-widget-with-chart.tsx
@@ -0,0 +1,357 @@
+"use client";
+
+import MoneyValues from "@/components/money-values";
+import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
+import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
+import { getIconComponent } from "@/lib/utils/icons";
+import { formatPeriodForUrl } from "@/lib/utils/period";
+import {
+ RiArrowDownLine,
+ RiArrowUpLine,
+ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
+import { WidgetEmptyState } from "../widget-empty-state";
+
+type IncomeByCategoryWidgetWithChartProps = {
+ data: IncomeByCategoryData;
+ period: string;
+};
+
+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";
+};
+
+const formatPercentage = (value: number) => {
+ return `${Math.abs(value).toFixed(1)}%`;
+};
+
+const formatCurrency = (value: number) =>
+ new Intl.NumberFormat("pt-BR", {
+ style: "currency",
+ currency: "BRL",
+ }).format(value);
+
+export function IncomeByCategoryWidgetWithChart({
+ data,
+ period,
+}: IncomeByCategoryWidgetWithChartProps) {
+ const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
+ const periodParam = formatPeriodForUrl(period);
+
+ // Configuração do chart com cores do CSS
+ const chartConfig = useMemo(() => {
+ const config: ChartConfig = {};
+ const colors = [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ "var(--chart-3)",
+ "var(--chart-4)",
+ "var(--chart-5)",
+ "var(--chart-1)",
+ "var(--chart-2)",
+ ];
+
+ if (data.categories.length <= 7) {
+ data.categories.forEach((category, index) => {
+ config[category.categoryId] = {
+ label: category.categoryName,
+ color: colors[index % colors.length],
+ };
+ });
+ } else {
+ // Top 7 + Outros
+ const top7 = data.categories.slice(0, 7);
+ top7.forEach((category, index) => {
+ config[category.categoryId] = {
+ label: category.categoryName,
+ color: colors[index % colors.length],
+ };
+ });
+ config["outros"] = {
+ label: "Outros",
+ color: "var(--chart-6)",
+ };
+ }
+
+ return config;
+ }, [data.categories]);
+
+ // Preparar dados para o gráfico de pizza - Top 7 + Outros
+ 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,
+ }));
+ }
+
+ // Pegar top 7 categorias
+ const top7 = data.categories.slice(0, 7);
+ const others = data.categories.slice(7);
+
+ // Somar o restante
+ const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
+ const othersPercentage = others.reduce(
+ (sum, cat) => sum + cat.percentageOfTotal,
+ 0
+ );
+
+ const top7Data = top7.map((category) => ({
+ category: category.categoryId,
+ name: category.categoryName,
+ value: category.currentAmount,
+ percentage: category.percentageOfTotal,
+ fill: chartConfig[category.categoryId]?.color,
+ }));
+
+ // Adicionar "Outros" se houver
+ if (others.length > 0) {
+ top7Data.push({
+ category: "outros",
+ name: "Outros",
+ value: othersTotal,
+ percentage: othersPercentage,
+ fill: chartConfig["outros"]?.color,
+ });
+ }
+
+ return top7Data;
+ }, [data.categories, chartConfig]);
+
+ if (data.categories.length === 0) {
+ return (
+ }
+ title="Nenhuma receita encontrada"
+ description="Quando houver receitas registradas, elas aparecerão aqui."
+ />
+ );
+ }
+
+ return (
+ setActiveTab(v as "list" | "chart")}
+ className="w-full"
+ >
+
+
+
+
+ Lista
+
+
+
+ Gráfico
+
+
+
+
+
+
+ {data.categories.map((category) => {
+ const IconComponent = category.categoryIcon
+ ? getIconComponent(category.categoryIcon)
+ : null;
+ const initials = buildInitials(category.categoryName);
+ 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;
+
+ return (
+
+
+
+
+ {IconComponent ? (
+
+ ) : (
+
+ {initials}
+
+ )}
+
+
+
+
+
+
+ {category.categoryName}
+
+
+
+
+
+
+ {formatPercentage(category.percentageOfTotal)} da
+ receita total
+
+
+
+
+
+
+
+ {category.percentageChange !== null && (
+
+ {hasIncrease && }
+ {hasDecrease && }
+ {formatPercentage(category.percentageChange)}
+
+ )}
+
+
+
+ {hasBudget &&
+ category.budgetUsedPercentage !== null &&
+ category.budgetAmount !== null && (
+
+
+
+ {budgetExceeded ? (
+ <>
+ {formatPercentage(category.budgetUsedPercentage)} do
+ limite {formatCurrency(category.budgetAmount)} -
+ excedeu em {formatCurrency(exceededAmount)}
+ >
+ ) : (
+ <>
+ {formatPercentage(category.budgetUsedPercentage)} do
+ limite {formatCurrency(category.budgetAmount)}
+ >
+ )}
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+ formatPercentage(entry.percentage)}
+ outerRadius={75}
+ dataKey="value"
+ nameKey="category"
+ />
+ {
+ if (active && payload && payload.length) {
+ const data = payload[0].payload;
+ return (
+
+
+
+
+ {data.name}
+
+
+ {formatCurrency(data.value)}
+
+
+ {formatPercentage(data.percentage)} do total
+
+
+
+
+ );
+ }
+ return null;
+ }}
+ />
+
+
+
+
+ {chartData.map((entry, index) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/dashboard/income-expense-balance-widget.tsx b/components/dashboard/income-expense-balance-widget.tsx
index 3bc8aa1..8f34d3b 100644
--- a/components/dashboard/income-expense-balance-widget.tsx
+++ b/components/dashboard/income-expense-balance-widget.tsx
@@ -103,7 +103,7 @@ export function IncomeExpenseBalanceWidget({
className="flex items-center gap-2"
>
@@ -144,7 +144,7 @@ export function IncomeExpenseBalanceWidget({
@@ -153,7 +153,7 @@ export function IncomeExpenseBalanceWidget({
@@ -162,7 +162,7 @@ export function IncomeExpenseBalanceWidget({
diff --git a/lib/dashboard/widgets/widgets-config.tsx b/lib/dashboard/widgets/widgets-config.tsx
index 3610b0b..1c4fd07 100644
--- a/lib/dashboard/widgets/widgets-config.tsx
+++ b/lib/dashboard/widgets/widgets-config.tsx
@@ -1,6 +1,8 @@
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
import { ExpensesByCategoryWidget } from "@/components/dashboard/expenses-by-category-widget";
+import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import { IncomeByCategoryWidget } from "@/components/dashboard/income-by-category-widget";
+import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
@@ -183,7 +185,7 @@ export const widgetsConfig: WidgetConfig[] = [
subtitle: "Distribuição de receitas por categoria",
icon: ,
component: ({ data, period }) => (
-
@@ -195,7 +197,7 @@ export const widgetsConfig: WidgetConfig[] = [
subtitle: "Distribuição de despesas por categoria",
icon: ,
component: ({ data, period }) => (
-
diff --git a/public/changelog.json b/public/changelog.json
index cc67998..f2d56b8 100644
--- a/public/changelog.json
+++ b/public/changelog.json
@@ -1,7 +1,39 @@
{
"version": "1.0.0",
- "generatedAt": "2025-12-08T15:16:10.003Z",
+ "generatedAt": "2025-12-10T16:45:04.592Z",
"entries": [
+ {
+ "id": "89765d4373b820a3e7c8e4fa40479dd2673558b0",
+ "type": "chore",
+ "title": "remover arquivo PLAN.md",
+ "date": "2025-12-09 17:26:08 +0000",
+ "icon": "🔧",
+ "category": "chore"
+ },
+ {
+ "id": "95d6a45a95c1a383dfa532aa72f764fcd4bff64e",
+ "type": "feat",
+ "title": "adicionar análise e sugestões para OpenSheets",
+ "date": "2025-12-09 17:24:07 +0000",
+ "icon": "✨",
+ "category": "feature"
+ },
+ {
+ "id": "0c445ee4a5a70dbeee834ba511ace1ea79471ada",
+ "type": "feat",
+ "title": "adicionar alerta de privacidade e ajustar estilos",
+ "date": "2025-12-09 17:23:45 +0000",
+ "icon": "✨",
+ "category": "feature"
+ },
+ {
+ "id": "ed2b7070ebd14c3274dcd515613d5eebbd990b24",
+ "type": "feat",
+ "title": "adicionar funcionalidades de leitura de atualizações",
+ "date": "2025-12-08 15:17:10 +0000",
+ "icon": "✨",
+ "category": "feature"
+ },
{
"id": "b7fcba77b7ed0f887ba26e2b0ceae19904e140cd",
"type": "feat",
@@ -129,38 +161,6 @@
"date": "2025-11-22 12:49:56 -0300",
"icon": "✨",
"category": "feature"
- },
- {
- "id": "4d076772e623cc3cb1a51f94551125ad9b791841",
- "type": "refactor",
- "title": "Relocate `PrivacyProvider` to the dashboard layout and update `tsconfig` `jsx` compiler option.",
- "date": "2025-11-21 09:40:41 -0300",
- "icon": "♻️",
- "category": "refactor"
- },
- {
- "id": "3d8772e55f2d25b757b0b3fe398f7db2fafcb745",
- "type": "feat",
- "title": "adiciona tipos para d3-array e ajusta configurações do TypeScript",
- "date": "2025-11-17 20:58:05 -0300",
- "icon": "✨",
- "category": "feature"
- },
- {
- "id": "a7736b7ab9249dd0e82b30f71ca74530dad0fdb0",
- "type": "feat",
- "title": "adicionar babel-plugin-react-compiler como dependência",
- "date": "2025-11-17 19:55:21 -0300",
- "icon": "✨",
- "category": "feature"
- },
- {
- "id": "835d94f140670888df920834ab2b77eb365362ce",
- "type": "chore",
- "title": "add package-lock.json for dependency version locking",
- "date": "2025-11-17 19:45:01 +0000",
- "icon": "🔧",
- "category": "chore"
}
]
}
\ No newline at end of file