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 *));
|
||||
|
||||
@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);
|
||||
|
||||
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"
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded"
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: config?.color }}
|
||||
/>
|
||||
<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 gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.receita.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -153,7 +153,7 @@ export function IncomeExpenseBalanceWidget({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.despesa.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -162,7 +162,7 @@ export function IncomeExpenseBalanceWidget({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: chartConfig.balanco.color }}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -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: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<IncomeByCategoryWidget
|
||||
<IncomeByCategoryWidgetWithChart
|
||||
data={data.incomeByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
@@ -195,7 +197,7 @@ export const widgetsConfig: WidgetConfig[] = [
|
||||
subtitle: "Distribuição de despesas por categoria",
|
||||
icon: <RiPieChartLine className="size-4" />,
|
||||
component: ({ data, period }) => (
|
||||
<ExpensesByCategoryWidget
|
||||
<ExpensesByCategoryWidgetWithChart
|
||||
data={data.expensesByCategoryData}
|
||||
period={period}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user