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 { ChartContainer, type ChartConfig } from "@/components/ui/chart";
|
||||
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 { formatPeriodForUrl } from "@/lib/utils/period";
|
||||
import {
|
||||
@@ -25,20 +30,6 @@ type ExpensesByCategoryWidgetWithChartProps = {
|
||||
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)}%`;
|
||||
};
|
||||
@@ -170,11 +161,13 @@ export function ExpensesByCategoryWidgetWithChart({
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div className="flex flex-col px-0">
|
||||
{data.categories.map((category) => {
|
||||
{data.categories.map((category, index) => {
|
||||
const IconComponent = category.categoryIcon
|
||||
? getIconComponent(category.categoryIcon)
|
||||
: null;
|
||||
const initials = buildInitials(category.categoryName);
|
||||
const initials = buildCategoryInitials(category.categoryName);
|
||||
const color = getCategoryColor(index);
|
||||
const bgColor = getCategoryBgColor(index);
|
||||
const hasIncrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange > 0;
|
||||
@@ -199,11 +192,17 @@ export function ExpensesByCategoryWidgetWithChart({
|
||||
>
|
||||
<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">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
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 {
|
||||
buildCategoryInitials,
|
||||
getCategoryBgColor,
|
||||
getCategoryColor,
|
||||
} from "@/lib/utils/category-colors";
|
||||
import { getIconComponent } from "@/lib/utils/icons";
|
||||
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||
import {
|
||||
@@ -25,20 +30,6 @@ type IncomeByCategoryWidgetWithChartProps = {
|
||||
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)}%`;
|
||||
};
|
||||
@@ -170,11 +161,13 @@ export function IncomeByCategoryWidgetWithChart({
|
||||
|
||||
<TabsContent value="list" className="mt-0">
|
||||
<div className="flex flex-col px-0">
|
||||
{data.categories.map((category) => {
|
||||
{data.categories.map((category, index) => {
|
||||
const IconComponent = category.categoryIcon
|
||||
? getIconComponent(category.categoryIcon)
|
||||
: null;
|
||||
const initials = buildInitials(category.categoryName);
|
||||
const initials = buildCategoryInitials(category.categoryName);
|
||||
const color = getCategoryColor(index);
|
||||
const bgColor = getCategoryBgColor(index);
|
||||
const hasIncrease =
|
||||
category.percentageChange !== null &&
|
||||
category.percentageChange > 0;
|
||||
@@ -199,11 +192,17 @@ export function IncomeByCategoryWidgetWithChart({
|
||||
>
|
||||
<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">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{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}
|
||||
</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