forked from git.gladyson/openmonetis
feat: topbar de navegação como experimento de UI (v1.7.0)
- Substitui header fixo por topbar com backdrop blur e navegação agrupada em 5 seções - Adiciona FerramentasDropdown consolidando calculadora e modo privacidade - NotificationBell expandida com orçamentos e pré-lançamentos - Remove logout-button, header-dashboard e privacy-mode-toggle como componentes separados - Logo refatorado com variante compact; topbar com links em lowercase - Adiciona dependência radix-ui ^1.4.3 - Atualiza CHANGELOG para v1.7.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { RiPieChartLine } from "@remixicon/react";
|
||||
import { useMemo } from "react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
type TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||
import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
|
||||
import { CATEGORY_COLORS } from "@/lib/utils/category-colors";
|
||||
|
||||
function AreaTooltip({ active, payload, label }: TooltipProps<number, string>) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const items = payload
|
||||
.filter((entry) => Number(entry.value) > 0)
|
||||
.sort((a, b) => Number(b.value) - Number(a.value));
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="min-w-[210px] rounded-lg border border-border/50 bg-background px-3 py-2.5 shadow-xl">
|
||||
<p className="mb-2.5 border-b border-border/50 pb-1.5 text-xs font-semibold text-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((entry) => (
|
||||
<div
|
||||
key={entry.dataKey}
|
||||
className="flex items-center justify-between gap-6"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums text-foreground">
|
||||
{currencyFormatter.format(Number(entry.value))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategoryReportChartProps {
|
||||
data: CategoryChartData;
|
||||
}
|
||||
|
||||
const CHART_COLORS = CATEGORY_COLORS;
|
||||
const LIMIT_OPTIONS = [
|
||||
{ value: "5", label: "Top 5" },
|
||||
{ value: "10", label: "Top 10" },
|
||||
{ value: "15", label: "Top 15" },
|
||||
] as const;
|
||||
|
||||
const MAX_CATEGORIES_IN_CHART = 15;
|
||||
const MAX_CATEGORIES = 15;
|
||||
|
||||
export function CategoryReportChart({ data }: CategoryReportChartProps) {
|
||||
const { chartData, categories } = data;
|
||||
const [limit, setLimit] = React.useState("10");
|
||||
|
||||
const { topCategories, filteredChartData } = React.useMemo(() => {
|
||||
const limitNum = Math.min(Number(limit), MAX_CATEGORIES);
|
||||
|
||||
const categoriesWithTotal = categories.map((category) => ({
|
||||
...category,
|
||||
total: chartData.reduce((sum, point) => {
|
||||
const v = point[category.name];
|
||||
return sum + (typeof v === "number" ? v : 0);
|
||||
}, 0),
|
||||
}));
|
||||
|
||||
const sorted = categoriesWithTotal
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, limitNum);
|
||||
|
||||
const filtered = chartData.map((point) => {
|
||||
const result: { month: string; [key: string]: number | string } = {
|
||||
month: point.month,
|
||||
};
|
||||
for (const cat of sorted) {
|
||||
result[cat.name] = (point[cat.name] as number) ?? 0;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
return { topCategories: sorted, filteredChartData: filtered };
|
||||
}, [categories, chartData, limit]);
|
||||
|
||||
const chartConfig = React.useMemo<ChartConfig>(() => {
|
||||
const config: ChartConfig = {};
|
||||
for (let i = 0; i < topCategories.length; i++) {
|
||||
const cat = topCategories[i];
|
||||
config[cat.name] = {
|
||||
label: cat.name,
|
||||
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
||||
};
|
||||
}
|
||||
return config;
|
||||
}, [topCategories]);
|
||||
|
||||
// Check if there's no data
|
||||
if (categories.length === 0 || chartData.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -40,165 +141,91 @@ export function CategoryReportChart({ data }: CategoryReportChartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get top 10 categories by total spending
|
||||
const { topCategories, filteredChartData } = useMemo(() => {
|
||||
// Calculate total for each category across all periods
|
||||
const categoriesWithTotal = categories.map((category) => {
|
||||
const total = chartData.reduce((sum, dataPoint) => {
|
||||
const value = dataPoint[category.name];
|
||||
return sum + (typeof value === "number" ? value : 0);
|
||||
}, 0);
|
||||
|
||||
return { ...category, total };
|
||||
});
|
||||
|
||||
// Sort by total (descending) and take top 10
|
||||
const sorted = categoriesWithTotal
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, MAX_CATEGORIES_IN_CHART);
|
||||
|
||||
// Filter chartData to include only top categories
|
||||
const _topCategoryNames = new Set(sorted.map((cat) => cat.name));
|
||||
const filtered = chartData.map((dataPoint) => {
|
||||
const filteredPoint: { month: string; [key: string]: number | string } = {
|
||||
month: dataPoint.month,
|
||||
};
|
||||
|
||||
// Only include data for top categories
|
||||
for (const cat of sorted) {
|
||||
if (dataPoint[cat.name] !== undefined) {
|
||||
filteredPoint[cat.name] = dataPoint[cat.name];
|
||||
}
|
||||
}
|
||||
|
||||
return filteredPoint;
|
||||
});
|
||||
|
||||
return { topCategories: sorted, filteredChartData: filtered };
|
||||
}, [categories, chartData]);
|
||||
const firstMonth = chartData[0]?.month ?? "";
|
||||
const lastMonth = chartData[chartData.length - 1]?.month ?? "";
|
||||
const periodLabel =
|
||||
firstMonth === lastMonth ? firstMonth : `${firstMonth} – ${lastMonth}`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Evolução por Categoria - Top {topCategories.length}
|
||||
</CardTitle>
|
||||
<Card className="pt-0">
|
||||
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
|
||||
<div className="grid flex-1 gap-1">
|
||||
<CardTitle>Evolução por Categoria</CardTitle>
|
||||
<CardDescription>{periodLabel}</CardDescription>
|
||||
</div>
|
||||
<Select value={limit} onValueChange={setLimit}>
|
||||
<SelectTrigger
|
||||
className="hidden w-[130px] rounded-lg sm:ml-auto sm:flex"
|
||||
aria-label="Número de categorias"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
{LIMIT_OPTIONS.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="rounded-lg"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={filteredChartData}>
|
||||
<defs>
|
||||
{topCategories.map((category, index) => {
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
return (
|
||||
<linearGradient
|
||||
key={category.id}
|
||||
id={`gradient-${category.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
className="text-xs"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||||
tickFormatter={(value) => {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(0)}k`;
|
||||
}
|
||||
return value.toString();
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<div className="mb-2 font-semibold">
|
||||
{payload[0]?.payload?.month}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{payload.map((entry, index) => {
|
||||
if (entry.dataKey === "month") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-4 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{currencyFormatter.format(
|
||||
Number(entry.value) || 0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{topCategories.map((category, index) => {
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[300px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredChartData}>
|
||||
<defs>
|
||||
{topCategories.map((cat, index) => {
|
||||
const color = CATEGORY_COLORS[index % CATEGORY_COLORS.length];
|
||||
return (
|
||||
<Area
|
||||
key={category.id}
|
||||
type="monotone"
|
||||
dataKey={category.name}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#gradient-${category.id})`}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<linearGradient
|
||||
key={cat.id}
|
||||
id={`fill-${cat.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</defs>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex flex-wrap gap-4">
|
||||
{topCategories.map((category, index) => {
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||
return (
|
||||
<div key={category.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{category.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
/>
|
||||
|
||||
<ChartTooltip cursor={false} content={<AreaTooltip />} />
|
||||
|
||||
{topCategories.map((cat, index) => (
|
||||
<Area
|
||||
key={cat.id}
|
||||
dataKey={cat.name}
|
||||
type="natural"
|
||||
fill={`url(#fill-${cat.id})`}
|
||||
stroke={CATEGORY_COLORS[index % CATEGORY_COLORS.length]}
|
||||
strokeWidth={1.5}
|
||||
stackId="a"
|
||||
/>
|
||||
))}
|
||||
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user