forked from git.gladyson/openmonetis
ajuste de layout mobile, melhorias e criação de novas funções. Detalhes adicionados no CHANGELOG.md
This commit is contained in:
committed by
Felipe Coutinho
parent
31fe752b7d
commit
ffde55f589
@@ -4,20 +4,19 @@ 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 { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { Cell, Pie, PieChart, Tooltip } from "recharts";
|
||||
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
|
||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { WidgetEmptyState } from "../widget-empty-state";
|
||||
|
||||
type ExpensesByCategoryWidgetWithChartProps = {
|
||||
@@ -35,11 +34,21 @@ const formatCurrency = (value: number) =>
|
||||
currency: "BRL",
|
||||
}).format(value);
|
||||
|
||||
type ChartDataItem = {
|
||||
category: string;
|
||||
name: string;
|
||||
value: number;
|
||||
percentage: number;
|
||||
fill: string | undefined;
|
||||
href: string | undefined;
|
||||
};
|
||||
|
||||
export function ExpensesByCategoryWidgetWithChart({
|
||||
data,
|
||||
period,
|
||||
}: ExpensesByCategoryWidgetWithChartProps) {
|
||||
const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
const periodParam = formatPeriodForUrl(period);
|
||||
|
||||
// Configuração do chart com cores do CSS
|
||||
@@ -80,50 +89,68 @@ export function ExpensesByCategoryWidgetWithChart({
|
||||
return config;
|
||||
}, [data.categories]);
|
||||
|
||||
// Preparar dados para o gráfico de pizza - Top 7 + Outros
|
||||
const chartData = useMemo(() => {
|
||||
// Preparar dados para o gráfico de pizza - Top 7 + Outros (com href para navegação)
|
||||
const chartData = useMemo((): ChartDataItem[] => {
|
||||
const buildItem = (
|
||||
categoryId: string,
|
||||
name: string,
|
||||
value: number,
|
||||
percentage: number,
|
||||
fill: string | undefined,
|
||||
): ChartDataItem => ({
|
||||
category: categoryId,
|
||||
name,
|
||||
value,
|
||||
percentage,
|
||||
fill,
|
||||
href:
|
||||
categoryId === "outros"
|
||||
? undefined
|
||||
: `/categorias/${categoryId}?periodo=${periodParam}`,
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
return data.categories.map((category) =>
|
||||
buildItem(
|
||||
category.categoryId,
|
||||
category.categoryName,
|
||||
category.currentAmount,
|
||||
category.percentageOfTotal,
|
||||
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
|
||||
const top7Data = top7.map((category) =>
|
||||
buildItem(
|
||||
category.categoryId,
|
||||
category.categoryName,
|
||||
category.currentAmount,
|
||||
category.percentageOfTotal,
|
||||
chartConfig[category.categoryId]?.color,
|
||||
),
|
||||
);
|
||||
if (others.length > 0) {
|
||||
top7Data.push({
|
||||
category: "outros",
|
||||
name: "Outros",
|
||||
value: othersTotal,
|
||||
percentage: othersPercentage,
|
||||
fill: chartConfig.outros?.color,
|
||||
});
|
||||
top7Data.push(
|
||||
buildItem(
|
||||
"outros",
|
||||
"Outros",
|
||||
othersTotal,
|
||||
othersPercentage,
|
||||
chartConfig.outros?.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return top7Data;
|
||||
}, [data.categories, chartConfig]);
|
||||
}, [data.categories, chartConfig, periodParam]);
|
||||
|
||||
if (data.categories.length === 0) {
|
||||
return (
|
||||
@@ -136,25 +163,146 @@ export function ExpensesByCategoryWidgetWithChart({
|
||||
}
|
||||
|
||||
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 className="flex flex-col gap-8">
|
||||
{/* Gráfico de pizza (donut) — fatias clicáveis */}
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[280px] w-full min-w-0 sm:h-[320px] sm:max-w-[360px]"
|
||||
>
|
||||
<PieChart margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="58%"
|
||||
outerRadius="92%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
nameKey="category"
|
||||
stroke="transparent"
|
||||
onClick={(payload: ChartDataItem) => {
|
||||
if (payload?.href) router.push(payload.href);
|
||||
}}
|
||||
label={(props: {
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
midAngle?: number;
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
percent?: number;
|
||||
}) => {
|
||||
const { cx = 0, cy = 0, midAngle = 0, innerRadius = 0, outerRadius = 0, percent = 0 } = props;
|
||||
const percentage = percent * 100;
|
||||
if (percentage < 6) return null;
|
||||
const radius = (Number(innerRadius) + Number(outerRadius)) / 2;
|
||||
const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180));
|
||||
const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180));
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-foreground text-[10px] font-medium"
|
||||
>
|
||||
{formatPercentage(percentage)}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.fill}
|
||||
className={
|
||||
entry.href
|
||||
? "cursor-pointer transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
: ""
|
||||
}
|
||||
style={
|
||||
entry.href
|
||||
? { filter: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.08))" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
{!isMobile && (
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
const d = payload[0].payload as ChartDataItem;
|
||||
return (
|
||||
<div className="rounded-xl border border-border/80 bg-card px-3 py-2.5 shadow-lg">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{d.name}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{formatCurrency(d.value)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatPercentage(d.percentage)} do total
|
||||
</span>
|
||||
{d.href && (
|
||||
<span className="mt-1 text-[10px] text-primary">
|
||||
Clique para ver detalhes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
cursor={false}
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
|
||||
{/* Legenda clicável */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-1 sm:flex-col sm:gap-1.5">
|
||||
{chartData.map((entry, index) => {
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-border/50"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground/80">
|
||||
{formatPercentage(entry.percentage)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return entry.href ? (
|
||||
<Link
|
||||
key={`legend-${index}`}
|
||||
href={entry.href}
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={`legend-${index}`}
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
{/* Lista de categorias */}
|
||||
<div className="border-t border-dashed pt-6">
|
||||
<div className="flex flex-col px-0">
|
||||
{data.categories.map((category, index) => {
|
||||
const hasIncrease =
|
||||
@@ -264,65 +412,7 @@ export function ExpensesByCategoryWidgetWithChart({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user