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) => ( +
+
+ + {entry.name} + +
+ ))} +
+
+ + + ); +} 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) => ( +
+
+ + {entry.name} + +
+ ))} +
+
+ + + ); +} 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