forked from git.gladyson/openmonetis
feat(dashboard): adicionar widgets de despesas e receitas com gráfico
- Adiciona o widget de despesas por categoria com gráfico. - Adiciona o widget de receitas por categoria com gráfico. - Atualiza a configuração dos widgets para incluir novos componentes. - Ajusta estilos e tamanhos de elementos nos widgets existentes.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--spacing-custom-height-1: 29rem;
|
--spacing-custom-height-1: 30rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -41,11 +41,12 @@
|
|||||||
--ring: oklch(69.18% 0.18855 38.353);
|
--ring: oklch(69.18% 0.18855 38.353);
|
||||||
|
|
||||||
/* Charts - harmonious, distinct, accessible */
|
/* Charts - harmonious, distinct, accessible */
|
||||||
--chart-1: oklch(65% 0.18 160);
|
--chart-1: var(--color-emerald-400);
|
||||||
--chart-2: oklch(60% 0.2 28);
|
--chart-2: var(--color-orange-400);
|
||||||
--chart-3: oklch(58% 0.19 295);
|
--chart-3: var(--color-indigo-400);
|
||||||
--chart-4: oklch(55% 0.2 260);
|
--chart-4: var(--color-amber-400);
|
||||||
--chart-5: oklch(68% 0.16 85);
|
--chart-5: var(--color-pink-400);
|
||||||
|
--chart-6: var(--color-stone-400);
|
||||||
|
|
||||||
/* Sidebar - slight elevation from background */
|
/* Sidebar - slight elevation from background */
|
||||||
--sidebar: oklch(94.637% 0.00925 62.27);
|
--sidebar: oklch(94.637% 0.00925 62.27);
|
||||||
@@ -121,11 +122,12 @@
|
|||||||
--ring: oklch(69.18% 0.18855 38.353);
|
--ring: oklch(69.18% 0.18855 38.353);
|
||||||
|
|
||||||
/* Charts - bright and distinct on dark */
|
/* Charts - bright and distinct on dark */
|
||||||
--chart-1: oklch(72% 0.17 158);
|
--chart-1: var(--color-emerald-500);
|
||||||
--chart-2: oklch(68% 0.19 30);
|
--chart-2: var(--color-orange-500);
|
||||||
--chart-3: oklch(68% 0.18 298);
|
--chart-3: var(--color-indigo-500);
|
||||||
--chart-4: oklch(65% 0.18 262);
|
--chart-4: var(--color-amber-500);
|
||||||
--chart-5: oklch(74% 0.15 88);
|
--chart-5: var(--color-pink-500);
|
||||||
|
--chart-6: var(--color-stone-500);
|
||||||
|
|
||||||
/* Sidebar - slight separation from main */
|
/* Sidebar - slight separation from main */
|
||||||
--sidebar: oklch(24.039% 0.00151 16.27);
|
--sidebar: oklch(24.039% 0.00151 16.27);
|
||||||
@@ -192,6 +194,7 @@
|
|||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-6: var(--chart-6);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
|||||||
354
components/dashboard/expenses-by-category-widget-with-chart.tsx
Normal file
354
components/dashboard/expenses-by-category-widget-with-chart.tsx
Normal file
@@ -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 (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhuma despesa encontrada"
|
||||||
|
description="Quando houver despesas registradas, elas aparecerão aqui."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<TabsList className="grid grid-cols-2">
|
||||||
|
<TabsTrigger value="list" className="text-xs">
|
||||||
|
<RiListUnordered className="size-3.5 mr-1" />
|
||||||
|
Lista
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="chart" className="text-xs">
|
||||||
|
<RiPieChart2Line className="size-3.5 mr-1" />
|
||||||
|
Gráfico
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="list" className="mt-0">
|
||||||
|
<div className="flex flex-col px-0">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={category.categoryId}
|
||||||
|
className="flex flex-col py-2 border-b border-dashed last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
||||||
|
{IconComponent ? (
|
||||||
|
<IconComponent className="size-4 text-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/categorias/${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 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{formatPercentage(category.percentageOfTotal)} da
|
||||||
|
despesa total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
|
<MoneyValues
|
||||||
|
className="text-foreground"
|
||||||
|
amount={category.currentAmount}
|
||||||
|
/>
|
||||||
|
{category.percentageChange !== null && (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-0.5 text-xs ${
|
||||||
|
hasIncrease
|
||||||
|
? "text-red-600 dark:text-red-500"
|
||||||
|
: hasDecrease
|
||||||
|
? "text-green-600 dark:text-green-500"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasIncrease && <RiArrowUpLine className="size-3" />}
|
||||||
|
{hasDecrease && <RiArrowDownLine className="size-3" />}
|
||||||
|
{formatPercentage(category.percentageChange)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasBudget && category.budgetUsedPercentage !== null && (
|
||||||
|
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||||
|
<RiWallet3Line
|
||||||
|
className={`size-3 ${
|
||||||
|
budgetExceeded
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-blue-600 dark:text-blue-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
budgetExceeded
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-blue-600 dark:text-blue-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{budgetExceeded ? (
|
||||||
|
<>
|
||||||
|
{formatPercentage(category.budgetUsedPercentage)} do
|
||||||
|
limite - excedeu em {formatCurrency(exceededAmount)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{formatPercentage(category.budgetUsedPercentage)} do
|
||||||
|
limite
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</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={(entry) => formatPercentage(entry.percentage)}
|
||||||
|
outerRadius={75}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="category"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
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-[0.70rem] uppercase text-muted-foreground">
|
||||||
|
{data.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-foreground">
|
||||||
|
{formatCurrency(data.value)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatPercentage(data.percentage)} do total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 min-w-[140px]">
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="size-3 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: entry.fill }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
components/dashboard/income-by-category-widget-with-chart.tsx
Normal file
357
components/dashboard/income-by-category-widget-with-chart.tsx
Normal file
@@ -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 (
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiPieChartLine className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhuma receita encontrada"
|
||||||
|
description="Quando houver receitas registradas, elas aparecerão aqui."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => setActiveTab(v as "list" | "chart")}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<TabsList className="grid grid-cols-2">
|
||||||
|
<TabsTrigger value="list" className="text-xs">
|
||||||
|
<RiListUnordered className="size-3.5 mr-1" />
|
||||||
|
Lista
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="chart" className="text-xs">
|
||||||
|
<RiPieChart2Line className="size-3.5 mr-1" />
|
||||||
|
Gráfico
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="list" className="mt-0">
|
||||||
|
<div className="flex flex-col px-0">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={category.categoryId}
|
||||||
|
className="flex flex-col gap-1.5 py-2 border-b border-dashed last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
||||||
|
{IconComponent ? (
|
||||||
|
<IconComponent className="size-4 text-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/categorias/${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 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{formatPercentage(category.percentageOfTotal)} da
|
||||||
|
receita total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||||
|
<MoneyValues
|
||||||
|
className="text-foreground"
|
||||||
|
amount={category.currentAmount}
|
||||||
|
/>
|
||||||
|
{category.percentageChange !== null && (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-0.5 text-xs ${
|
||||||
|
hasIncrease
|
||||||
|
? "text-green-600 dark:text-green-500"
|
||||||
|
: hasDecrease
|
||||||
|
? "text-red-600 dark:text-red-500"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasIncrease && <RiArrowUpLine className="size-3" />}
|
||||||
|
{hasDecrease && <RiArrowDownLine className="size-3" />}
|
||||||
|
{formatPercentage(category.percentageChange)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasBudget &&
|
||||||
|
category.budgetUsedPercentage !== null &&
|
||||||
|
category.budgetAmount !== null && (
|
||||||
|
<div className="ml-11 flex items-center gap-1.5 text-xs">
|
||||||
|
<RiWallet3Line
|
||||||
|
className={`size-3 ${
|
||||||
|
budgetExceeded
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-blue-600 dark:text-blue-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
budgetExceeded
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-blue-600 dark:text-blue-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{budgetExceeded ? (
|
||||||
|
<>
|
||||||
|
{formatPercentage(category.budgetUsedPercentage)} do
|
||||||
|
limite {formatCurrency(category.budgetAmount)} -
|
||||||
|
excedeu em {formatCurrency(exceededAmount)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{formatPercentage(category.budgetUsedPercentage)} do
|
||||||
|
limite {formatCurrency(category.budgetAmount)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</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={(entry) => formatPercentage(entry.percentage)}
|
||||||
|
outerRadius={75}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="category"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
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-[0.70rem] uppercase text-muted-foreground">
|
||||||
|
{data.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-foreground">
|
||||||
|
{formatCurrency(data.value)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatPercentage(data.percentage)} do total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 min-w-[140px]">
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<div key={`legend-${index}`} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="size-3 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: entry.fill }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="h-2.5 w-2.5 rounded"
|
className="h-3 w-3 rounded-full"
|
||||||
style={{ backgroundColor: config?.color }}
|
style={{ backgroundColor: config?.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -144,7 +144,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
<div className="flex items-center justify-center gap-6">
|
<div className="flex items-center justify-center gap-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded"
|
className="h-3 w-3 rounded-full"
|
||||||
style={{ backgroundColor: chartConfig.receita.color }}
|
style={{ backgroundColor: chartConfig.receita.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -153,7 +153,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded"
|
className="h-3 w-3 rounded-full"
|
||||||
style={{ backgroundColor: chartConfig.despesa.color }}
|
style={{ backgroundColor: chartConfig.despesa.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -162,7 +162,7 @@ export function IncomeExpenseBalanceWidget({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded"
|
className="h-3 w-3 rounded-full"
|
||||||
style={{ backgroundColor: chartConfig.balanco.color }}
|
style={{ backgroundColor: chartConfig.balanco.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
|
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
|
||||||
import { ExpensesByCategoryWidget } from "@/components/dashboard/expenses-by-category-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 { 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 { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
|
||||||
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
|
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
|
||||||
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
|
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
|
||||||
@@ -183,7 +185,7 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
subtitle: "Distribuição de receitas por categoria",
|
subtitle: "Distribuição de receitas por categoria",
|
||||||
icon: <RiPieChartLine className="size-4" />,
|
icon: <RiPieChartLine className="size-4" />,
|
||||||
component: ({ data, period }) => (
|
component: ({ data, period }) => (
|
||||||
<IncomeByCategoryWidget
|
<IncomeByCategoryWidgetWithChart
|
||||||
data={data.incomeByCategoryData}
|
data={data.incomeByCategoryData}
|
||||||
period={period}
|
period={period}
|
||||||
/>
|
/>
|
||||||
@@ -195,7 +197,7 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
subtitle: "Distribuição de despesas por categoria",
|
subtitle: "Distribuição de despesas por categoria",
|
||||||
icon: <RiPieChartLine className="size-4" />,
|
icon: <RiPieChartLine className="size-4" />,
|
||||||
component: ({ data, period }) => (
|
component: ({ data, period }) => (
|
||||||
<ExpensesByCategoryWidget
|
<ExpensesByCategoryWidgetWithChart
|
||||||
data={data.expensesByCategoryData}
|
data={data.expensesByCategoryData}
|
||||||
period={period}
|
period={period}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,39 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"generatedAt": "2025-12-08T15:16:10.003Z",
|
"generatedAt": "2025-12-10T16:45:04.592Z",
|
||||||
"entries": [
|
"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",
|
"id": "b7fcba77b7ed0f887ba26e2b0ceae19904e140cd",
|
||||||
"type": "feat",
|
"type": "feat",
|
||||||
@@ -129,38 +161,6 @@
|
|||||||
"date": "2025-11-22 12:49:56 -0300",
|
"date": "2025-11-22 12:49:56 -0300",
|
||||||
"icon": "✨",
|
"icon": "✨",
|
||||||
"category": "feature"
|
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user