forked from git.gladyson/openmonetis
feat(dashboard): adicionar ícones coloridos nos widgets de categorias
- Criar utilitário centralizado para cores de categorias (lib/utils/category-colors.ts) - Aplicar ícones coloridos no widget de despesas por categoria - Aplicar ícones coloridos no widget de receitas por categoria - Refatorar top-categories para usar utilitário centralizado Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
|||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
|
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
|
||||||
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
|
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
|
||||||
|
import {
|
||||||
|
buildCategoryInitials,
|
||||||
|
getCategoryBgColor,
|
||||||
|
getCategoryColor,
|
||||||
|
} from "@/lib/utils/category-colors";
|
||||||
import { getIconComponent } from "@/lib/utils/icons";
|
import { getIconComponent } from "@/lib/utils/icons";
|
||||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
import {
|
import {
|
||||||
@@ -25,20 +30,6 @@ type ExpensesByCategoryWidgetWithChartProps = {
|
|||||||
period: string;
|
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) => {
|
const formatPercentage = (value: number) => {
|
||||||
return `${Math.abs(value).toFixed(0)}%`;
|
return `${Math.abs(value).toFixed(0)}%`;
|
||||||
};
|
};
|
||||||
@@ -170,11 +161,13 @@ export function ExpensesByCategoryWidgetWithChart({
|
|||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
<TabsContent value="list" className="mt-0">
|
||||||
<div className="flex flex-col px-0">
|
<div className="flex flex-col px-0">
|
||||||
{data.categories.map((category) => {
|
{data.categories.map((category, index) => {
|
||||||
const IconComponent = category.categoryIcon
|
const IconComponent = category.categoryIcon
|
||||||
? getIconComponent(category.categoryIcon)
|
? getIconComponent(category.categoryIcon)
|
||||||
: null;
|
: null;
|
||||||
const initials = buildInitials(category.categoryName);
|
const initials = buildCategoryInitials(category.categoryName);
|
||||||
|
const color = getCategoryColor(index);
|
||||||
|
const bgColor = getCategoryBgColor(index);
|
||||||
const hasIncrease =
|
const hasIncrease =
|
||||||
category.percentageChange !== null &&
|
category.percentageChange !== null &&
|
||||||
category.percentageChange > 0;
|
category.percentageChange > 0;
|
||||||
@@ -199,11 +192,17 @@ export function ExpensesByCategoryWidgetWithChart({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<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">
|
<div
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
{IconComponent ? (
|
{IconComponent ? (
|
||||||
<IconComponent className="size-4 text-foreground" />
|
<IconComponent className="size-4" style={{ color }} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
<span
|
||||||
|
className="text-xs font-semibold uppercase"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
|
import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
|
||||||
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
|
import type { IncomeByCategoryData } from "@/lib/dashboard/categories/income-by-category";
|
||||||
|
import {
|
||||||
|
buildCategoryInitials,
|
||||||
|
getCategoryBgColor,
|
||||||
|
getCategoryColor,
|
||||||
|
} from "@/lib/utils/category-colors";
|
||||||
import { getIconComponent } from "@/lib/utils/icons";
|
import { getIconComponent } from "@/lib/utils/icons";
|
||||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
import {
|
import {
|
||||||
@@ -25,20 +30,6 @@ type IncomeByCategoryWidgetWithChartProps = {
|
|||||||
period: string;
|
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) => {
|
const formatPercentage = (value: number) => {
|
||||||
return `${Math.abs(value).toFixed(1)}%`;
|
return `${Math.abs(value).toFixed(1)}%`;
|
||||||
};
|
};
|
||||||
@@ -170,11 +161,13 @@ export function IncomeByCategoryWidgetWithChart({
|
|||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
<TabsContent value="list" className="mt-0">
|
||||||
<div className="flex flex-col px-0">
|
<div className="flex flex-col px-0">
|
||||||
{data.categories.map((category) => {
|
{data.categories.map((category, index) => {
|
||||||
const IconComponent = category.categoryIcon
|
const IconComponent = category.categoryIcon
|
||||||
? getIconComponent(category.categoryIcon)
|
? getIconComponent(category.categoryIcon)
|
||||||
: null;
|
: null;
|
||||||
const initials = buildInitials(category.categoryName);
|
const initials = buildCategoryInitials(category.categoryName);
|
||||||
|
const color = getCategoryColor(index);
|
||||||
|
const bgColor = getCategoryBgColor(index);
|
||||||
const hasIncrease =
|
const hasIncrease =
|
||||||
category.percentageChange !== null &&
|
category.percentageChange !== null &&
|
||||||
category.percentageChange > 0;
|
category.percentageChange > 0;
|
||||||
@@ -199,11 +192,17 @@ export function IncomeByCategoryWidgetWithChart({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<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">
|
<div
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
{IconComponent ? (
|
{IconComponent ? (
|
||||||
<IconComponent className="size-4 text-foreground" />
|
<IconComponent className="size-4" style={{ color }} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
<span
|
||||||
|
className="text-xs font-semibold uppercase"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
130
components/top-estabelecimentos/top-categories.tsx
Normal file
130
components/top-estabelecimentos/top-categories.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import MoneyValues from "@/components/money-values";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
||||||
|
import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data";
|
||||||
|
import {
|
||||||
|
buildCategoryInitials,
|
||||||
|
getCategoryBgColor,
|
||||||
|
getCategoryColor,
|
||||||
|
} from "@/lib/utils/category-colors";
|
||||||
|
import { getIconComponent } from "@/lib/utils/icons";
|
||||||
|
import { title_font } from "@/public/fonts/font_index";
|
||||||
|
import { RiPriceTag3Line } from "@remixicon/react";
|
||||||
|
|
||||||
|
type TopCategoriesProps = {
|
||||||
|
categories: TopEstabelecimentosData["topCategories"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopCategories({ categories }: TopCategoriesProps) {
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle
|
||||||
|
className={`${title_font.className} flex items-center gap-1.5 text-base`}
|
||||||
|
>
|
||||||
|
<RiPriceTag3Line className="size-4 text-primary" />
|
||||||
|
Principais Categorias
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<WidgetEmptyState
|
||||||
|
icon={<RiPriceTag3Line className="size-6 text-muted-foreground" />}
|
||||||
|
title="Nenhuma categoria encontrada"
|
||||||
|
description="Quando houver despesas categorizadas, elas aparecerão aqui."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = categories.reduce((acc, c) => acc + c.totalAmount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle
|
||||||
|
className={`${title_font.className} flex items-center gap-1.5 text-base`}
|
||||||
|
>
|
||||||
|
<RiPriceTag3Line className="size-4 text-primary" />
|
||||||
|
Principais Categorias
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{categories.map((category, index) => {
|
||||||
|
const IconComponent = category.icon
|
||||||
|
? getIconComponent(category.icon)
|
||||||
|
: null;
|
||||||
|
const color = getCategoryColor(index);
|
||||||
|
const bgColor = getCategoryBgColor(index);
|
||||||
|
const initials = buildCategoryInitials(category.name);
|
||||||
|
const percent =
|
||||||
|
totalAmount > 0 ? (category.totalAmount / totalAmount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
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"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{IconComponent ? (
|
||||||
|
<IconComponent className="size-4" style={{ color }} />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-xs font-semibold uppercase"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and percentage */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm font-medium truncate block">
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{percent.toFixed(0)}% do total •{" "}
|
||||||
|
{category.transactionCount}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<div className="flex shrink-0 flex-col items-end">
|
||||||
|
<MoneyValues
|
||||||
|
className="text-foreground"
|
||||||
|
amount={category.totalAmount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="ml-12 mt-1.5">
|
||||||
|
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${percent}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
lib/utils/category-colors.ts
Normal file
46
lib/utils/category-colors.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Cores para categorias em widgets e listas
|
||||||
|
* Usadas para colorir ícones e backgrounds de categorias
|
||||||
|
*/
|
||||||
|
export const CATEGORY_COLORS = [
|
||||||
|
"#ef4444", // red
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#10b981", // emerald
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#14b8a6", // teal
|
||||||
|
"#f97316", // orange
|
||||||
|
"#6366f1", // indigo
|
||||||
|
"#84cc16", // lime
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna a cor para um índice específico (com ciclo)
|
||||||
|
*/
|
||||||
|
export function getCategoryColor(index: number): string {
|
||||||
|
return CATEGORY_COLORS[index % CATEGORY_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna a cor de background com transparência
|
||||||
|
*/
|
||||||
|
export function getCategoryBgColor(index: number): string {
|
||||||
|
const color = getCategoryColor(index);
|
||||||
|
return `${color}15`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera iniciais a partir de um nome
|
||||||
|
*/
|
||||||
|
export function buildCategoryInitials(value: string): 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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user