forked from git.gladyson/openmonetis
feat(relatorios): reorganizar páginas e criar componente CategoryIconBadge
- Renomear /relatorios/categorias para /relatorios/tendencias - Renomear /relatorios/cartoes para /relatorios/uso-cartoes - Criar componente CategoryIconBadge unificado com cores dinâmicas - Atualizar cards de categorias com novo layout (ações no footer) - Atualizar cards de orçamentos com CategoryIconBadge - Adicionar tooltip detalhado nas células de tendências (valor anterior e diferença) - Adicionar dot colorido (verde/vermelho) para indicar tipo de categoria Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
// Redirect to default if validation fails
|
// Redirect to default if validation fails
|
||||||
redirect(
|
redirect(
|
||||||
`/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
|
`/relatorios/tendencias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +130,11 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{categoriesByType[type].map((category) => (
|
{categoriesByType[type].map((category, index) => (
|
||||||
<CategoryCard
|
<CategoryCard
|
||||||
key={category.id}
|
key={category.id}
|
||||||
category={category}
|
category={category}
|
||||||
|
colorIndex={index}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onRemove={handleRemoveRequest}
|
onRemove={handleRemoveRequest}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,94 +1,103 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiDeleteBin5Line, RiMore2Fill, RiPencilLine } from "@remixicon/react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
RiDeleteBin5Line,
|
||||||
DropdownMenuContent,
|
RiFileList2Line,
|
||||||
DropdownMenuItem,
|
RiPencilLine,
|
||||||
DropdownMenuTrigger,
|
} from "@remixicon/react";
|
||||||
} from "@/components/ui/dropdown-menu";
|
import Link from "next/link";
|
||||||
import { TypeBadge } from "../type-badge";
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "./category-icon";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
import { CategoryIconBadge } from "./category-icon-badge";
|
||||||
import type { Category } from "./types";
|
import type { Category } from "./types";
|
||||||
|
|
||||||
interface CategoryCardProps {
|
interface CategoryCardProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
|
colorIndex: number;
|
||||||
onEdit: (category: Category) => void;
|
onEdit: (category: Category) => void;
|
||||||
onRemove: (category: Category) => void;
|
onRemove: (category: Category) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryCard({
|
export function CategoryCard({
|
||||||
category,
|
category,
|
||||||
|
colorIndex,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: CategoryCardProps) {
|
}: CategoryCardProps) {
|
||||||
// Categorias protegidas que não podem ser editadas ou removidas
|
|
||||||
const categoriasProtegidas = [
|
const categoriasProtegidas = [
|
||||||
"Transferência interna",
|
"Transferência interna",
|
||||||
"Saldo inicial",
|
"Saldo inicial",
|
||||||
"Pagamentos",
|
"Pagamentos",
|
||||||
];
|
];
|
||||||
const isProtegida = categoriasProtegidas.includes(category.name);
|
const isProtegida = categoriasProtegidas.includes(category.name);
|
||||||
const canEdit = !isProtegida;
|
|
||||||
const canRemove = !isProtegida;
|
const actions = [
|
||||||
|
{
|
||||||
|
label: "editar",
|
||||||
|
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||||
|
onClick: () => onEdit(category),
|
||||||
|
variant: "default" as const,
|
||||||
|
disabled: isProtegida,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "detalhes",
|
||||||
|
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||||
|
href: `/categorias/${category.id}`,
|
||||||
|
variant: "default" as const,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "remover",
|
||||||
|
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||||
|
onClick: () => onRemove(category),
|
||||||
|
variant: "destructive" as const,
|
||||||
|
disabled: isProtegida,
|
||||||
|
},
|
||||||
|
].filter((action) => !action.disabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group py-2">
|
<Card className="flex h-full flex-col gap-0 py-3">
|
||||||
<CardContent className="p-2">
|
<CardContent className="flex flex-1 flex-col">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-start gap-2">
|
<CategoryIconBadge
|
||||||
<span className="flex size-11 items-center justify-center text-primary">
|
icon={category.icon}
|
||||||
<CategoryIcon name={category.icon} className="size-6" />
|
name={category.name}
|
||||||
</span>
|
colorIndex={colorIndex}
|
||||||
<div className="space-y-1">
|
size="md"
|
||||||
<h3 className="text-base font-medium leading-tight">
|
/>
|
||||||
<Link
|
<h3 className="leading-tight">{category.name}</h3>
|
||||||
href={`/categorias/${category.id}`}
|
|
||||||
className="underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
{category.name}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<TypeBadge type={category.type} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<RiMore2Fill className="size-4" />
|
|
||||||
<span className="sr-only">Abrir ações da categoria</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => onEdit(category)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
>
|
|
||||||
<RiPencilLine className="mr-2 size-4" />
|
|
||||||
Editar
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onSelect={() => onRemove(category)}
|
|
||||||
disabled={!canRemove}
|
|
||||||
>
|
|
||||||
<RiDeleteBin5Line className="mr-2 size-4" />
|
|
||||||
Remover
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex flex-wrap gap-3 px-6 pt-4 text-sm">
|
||||||
|
{actions.map(({ label, icon, onClick, href, variant }) => {
|
||||||
|
const className = cn(
|
||||||
|
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
|
||||||
|
variant === "destructive" ? "text-destructive" : "text-primary",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link key={label} href={href} className={className}>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
|
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
|
||||||
import type { CategoryType } from "@/lib/categorias/constants";
|
import type { CategoryType } from "@/lib/categorias/constants";
|
||||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||||
import { getIconComponent } from "@/lib/utils/icons";
|
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
import { CategoryIconBadge } from "./category-icon-badge";
|
||||||
import { TypeBadge } from "../type-badge";
|
import { TypeBadge } from "../type-badge";
|
||||||
import { Card } from "../ui/card";
|
import { Card } from "../ui/card";
|
||||||
|
|
||||||
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";
|
|
||||||
};
|
|
||||||
|
|
||||||
type CategorySummary = {
|
type CategorySummary = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -46,9 +32,6 @@ export function CategoryDetailHeader({
|
|||||||
percentageChange,
|
percentageChange,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
}: CategoryDetailHeaderProps) {
|
}: CategoryDetailHeaderProps) {
|
||||||
const IconComponent = category.icon ? getIconComponent(category.icon) : null;
|
|
||||||
const initials = buildInitials(category.name);
|
|
||||||
|
|
||||||
const isIncrease =
|
const isIncrease =
|
||||||
typeof percentageChange === "number" && percentageChange > 0;
|
typeof percentageChange === "number" && percentageChange > 0;
|
||||||
const isDecrease =
|
const isDecrease =
|
||||||
@@ -87,15 +70,12 @@ export function CategoryDetailHeader({
|
|||||||
<Card className="px-4">
|
<Card className="px-4">
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="flex size-12 items-center justify-center rounded-xl bg-muted">
|
<CategoryIconBadge
|
||||||
{IconComponent ? (
|
icon={category.icon}
|
||||||
<IconComponent className="size-6" aria-hidden />
|
name={category.name}
|
||||||
) : (
|
colorIndex={0}
|
||||||
<span className="text-sm font-semibold uppercase text-muted-foreground">
|
size="lg"
|
||||||
{initials}
|
/>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-xl font-semibold leading-tight">
|
<h1 className="text-xl font-semibold leading-tight">
|
||||||
{category.name}
|
{category.name}
|
||||||
|
|||||||
78
components/categorias/category-icon-badge.tsx
Normal file
78
components/categorias/category-icon-badge.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCategoryInitials,
|
||||||
|
getCategoryBgColor,
|
||||||
|
getCategoryColor,
|
||||||
|
} from "@/lib/utils/category-colors";
|
||||||
|
import { getIconComponent } from "@/lib/utils/icons";
|
||||||
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
|
const sizeVariants = {
|
||||||
|
sm: {
|
||||||
|
container: "size-8",
|
||||||
|
icon: "size-4",
|
||||||
|
text: "text-[10px]",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
container: "size-9",
|
||||||
|
icon: "size-5",
|
||||||
|
text: "text-xs",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
container: "size-12",
|
||||||
|
icon: "size-6",
|
||||||
|
text: "text-sm",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CategoryIconBadgeSize = keyof typeof sizeVariants;
|
||||||
|
|
||||||
|
export interface CategoryIconBadgeProps {
|
||||||
|
/** Nome do ícone Remix (ex: "RiShoppingBag3Line") */
|
||||||
|
icon?: string | null;
|
||||||
|
/** Nome da categoria (usado para gerar iniciais como fallback) */
|
||||||
|
name: string;
|
||||||
|
/** Índice para determinar a cor (cicla entre as cores disponíveis) */
|
||||||
|
colorIndex: number;
|
||||||
|
/** Tamanho do badge: sm (32px), md (36px), lg (48px) */
|
||||||
|
size?: CategoryIconBadgeSize;
|
||||||
|
/** Classes adicionais para o container */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryIconBadge({
|
||||||
|
icon,
|
||||||
|
name,
|
||||||
|
colorIndex,
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
}: CategoryIconBadgeProps) {
|
||||||
|
const IconComponent = icon ? getIconComponent(icon) : null;
|
||||||
|
const initials = buildCategoryInitials(name);
|
||||||
|
const color = getCategoryColor(colorIndex);
|
||||||
|
const bgColor = getCategoryBgColor(colorIndex);
|
||||||
|
const variant = sizeVariants[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center justify-center overflow-hidden rounded-lg",
|
||||||
|
variant.container,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{IconComponent ? (
|
||||||
|
<IconComponent className={variant.icon} style={{ color }} />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn("font-semibold uppercase", variant.text)}
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,15 +12,10 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Pie, PieChart, Tooltip } from "recharts";
|
import { Pie, PieChart, Tooltip } from "recharts";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
import { type ChartConfig, ChartContainer } 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 { formatPeriodForUrl } from "@/lib/utils/period";
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { WidgetEmptyState } from "../widget-empty-state";
|
||||||
@@ -162,12 +157,6 @@ 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, index) => {
|
{data.categories.map((category, index) => {
|
||||||
const IconComponent = category.categoryIcon
|
|
||||||
? getIconComponent(category.categoryIcon)
|
|
||||||
: null;
|
|
||||||
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;
|
||||||
@@ -192,21 +181,11 @@ 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
|
<CategoryIconBadge
|
||||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
icon={category.categoryIcon}
|
||||||
style={{ backgroundColor: bgColor }}
|
name={category.categoryName}
|
||||||
>
|
colorIndex={index}
|
||||||
{IconComponent ? (
|
/>
|
||||||
<IconComponent className="size-4" style={{ color }} />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="text-xs font-semibold uppercase"
|
|
||||||
style={{ color }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -12,15 +12,10 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Pie, PieChart, Tooltip } from "recharts";
|
import { Pie, PieChart, Tooltip } from "recharts";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
import { type ChartConfig, ChartContainer } 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 { formatPeriodForUrl } from "@/lib/utils/period";
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import { WidgetEmptyState } from "../widget-empty-state";
|
import { WidgetEmptyState } from "../widget-empty-state";
|
||||||
@@ -162,12 +157,6 @@ 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, index) => {
|
{data.categories.map((category, index) => {
|
||||||
const IconComponent = category.categoryIcon
|
|
||||||
? getIconComponent(category.categoryIcon)
|
|
||||||
: null;
|
|
||||||
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;
|
||||||
@@ -192,21 +181,11 @@ 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
|
<CategoryIconBadge
|
||||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
icon={category.categoryIcon}
|
||||||
style={{ backgroundColor: bgColor }}
|
name={category.categoryName}
|
||||||
>
|
colorIndex={index}
|
||||||
{IconComponent ? (
|
/>
|
||||||
<IconComponent className="size-4" style={{ color }} />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="text-xs font-semibold uppercase"
|
|
||||||
style={{ color }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiDeleteBin5Line, RiPencilLine } from "@remixicon/react";
|
import { RiDeleteBin5Line, RiPencilLine } from "@remixicon/react";
|
||||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
@@ -10,6 +10,7 @@ import type { Budget } from "./types";
|
|||||||
|
|
||||||
interface BudgetCardProps {
|
interface BudgetCardProps {
|
||||||
budget: Budget;
|
budget: Budget;
|
||||||
|
colorIndex: number;
|
||||||
periodLabel: string;
|
periodLabel: string;
|
||||||
onEdit: (budget: Budget) => void;
|
onEdit: (budget: Budget) => void;
|
||||||
onRemove: (budget: Budget) => void;
|
onRemove: (budget: Budget) => void;
|
||||||
@@ -28,6 +29,7 @@ const formatCategoryName = (budget: Budget) =>
|
|||||||
|
|
||||||
export function BudgetCard({
|
export function BudgetCard({
|
||||||
budget,
|
budget,
|
||||||
|
colorIndex,
|
||||||
periodLabel,
|
periodLabel,
|
||||||
onEdit,
|
onEdit,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -41,12 +43,12 @@ export function BudgetCard({
|
|||||||
<Card className="flex h-full flex-col">
|
<Card className="flex h-full flex-col">
|
||||||
<CardContent className="flex h-full flex-col gap-4">
|
<CardContent className="flex h-full flex-col gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center text-primary">
|
<CategoryIconBadge
|
||||||
<CategoryIcon
|
icon={budget.category?.icon ?? undefined}
|
||||||
name={budget.category?.icon ?? undefined}
|
name={formatCategoryName(budget)}
|
||||||
className="size-6"
|
colorIndex={colorIndex}
|
||||||
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-base font-semibold leading-tight">
|
<h3 className="text-base font-semibold leading-tight">
|
||||||
{formatCategoryName(budget)}
|
{formatCategoryName(budget)}
|
||||||
|
|||||||
@@ -129,10 +129,11 @@ export function BudgetsPage({
|
|||||||
|
|
||||||
{hasBudgets ? (
|
{hasBudgets ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{budgets.map((budget) => (
|
{budgets.map((budget, index) => (
|
||||||
<BudgetCard
|
<BudgetCard
|
||||||
key={budget.id}
|
key={budget.id}
|
||||||
budget={budget}
|
budget={budget}
|
||||||
|
colorIndex={index}
|
||||||
periodLabel={periodLabel}
|
periodLabel={periodLabel}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onRemove={handleRemoveRequest}
|
onRemove={handleRemoveRequest}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiPieChartLine } from "@remixicon/react";
|
import { RiPieChartLine } from "@remixicon/react";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
||||||
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
|
import type { CardDetailData } from "@/lib/relatorios/cartoes-report";
|
||||||
import {
|
|
||||||
buildCategoryInitials,
|
|
||||||
getCategoryBgColor,
|
|
||||||
getCategoryColor,
|
|
||||||
} from "@/lib/utils/category-colors";
|
|
||||||
import { getIconComponent } from "@/lib/utils/icons";
|
|
||||||
import { title_font } from "@/public/fonts/font_index";
|
import { title_font } from "@/public/fonts/font_index";
|
||||||
|
|
||||||
type CardCategoryBreakdownProps = {
|
type CardCategoryBreakdownProps = {
|
||||||
@@ -56,36 +51,18 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
|
|||||||
|
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{data.map((category, index) => {
|
{data.map((category, index) => (
|
||||||
const IconComponent = category.icon
|
|
||||||
? getIconComponent(category.icon)
|
|
||||||
: null;
|
|
||||||
const color = getCategoryColor(index);
|
|
||||||
const bgColor = getCategoryBgColor(index);
|
|
||||||
const initials = buildCategoryInitials(category.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
className="flex flex-col py-2 border-b border-dashed last:border-0"
|
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 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
|
<CategoryIconBadge
|
||||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
icon={category.icon}
|
||||||
style={{ backgroundColor: bgColor }}
|
name={category.name}
|
||||||
>
|
colorIndex={index}
|
||||||
{IconComponent ? (
|
/>
|
||||||
<IconComponent className="size-4" style={{ color }} />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="text-xs font-semibold uppercase"
|
|
||||||
style={{ color }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name and percentage */}
|
{/* Name and percentage */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -108,12 +85,11 @@ export function CardCategoryBreakdown({ data }: CardCategoryBreakdownProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="ml-12 mt-1.5">
|
<div className="ml-11 mt-1.5">
|
||||||
<Progress className="h-1.5" value={category.percent} />
|
<Progress className="h-1.5" value={category.percent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function CardTopExpenses({ data }: CardTopExpensesProps) {
|
|||||||
<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">
|
||||||
{/* Rank number */}
|
{/* Rank number */}
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
|
||||||
<span className="text-sm font-semibold text-muted-foreground">
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (periodoParam) params.set("periodo", periodoParam);
|
if (periodoParam) params.set("periodo", periodoParam);
|
||||||
params.set("cartao", cardId);
|
params.set("cartao", cardId);
|
||||||
return `/relatorios/cartoes?${params.toString()}`;
|
return `/relatorios/uso-cartoes?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const summaryCards = [
|
const summaryCards = [
|
||||||
@@ -140,7 +140,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
|
|||||||
alt={card.name}
|
alt={card.name}
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
className="rounded object-contain"
|
className="rounded-sm object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RiBankCard2Line className="size-5 text-muted-foreground" />
|
<RiBankCard2Line className="size-5 text-muted-foreground" />
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
|
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { formatCurrency, formatPercentageChange } from "@/lib/relatorios/utils";
|
import { formatCurrency, formatPercentageChange } from "@/lib/relatorios/utils";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
@@ -22,11 +27,15 @@ export function CategoryCell({
|
|||||||
? ((value - previousValue) / previousValue) * 100
|
? ((value - previousValue) / previousValue) * 100
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const absoluteChange = !isFirstMonth ? value - previousValue : null;
|
||||||
|
|
||||||
const isIncrease = percentageChange !== null && percentageChange > 0;
|
const isIncrease = percentageChange !== null && percentageChange > 0;
|
||||||
const isDecrease = percentageChange !== null && percentageChange < 0;
|
const isDecrease = percentageChange !== null && percentageChange < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-end gap-0.5 min-h-9">
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex flex-col items-end gap-0.5 min-h-9 justify-center cursor-default px-4 py-2">
|
||||||
<span className="font-medium">{formatCurrency(value)}</span>
|
<span className="font-medium">{formatCurrency(value)}</span>
|
||||||
{!isFirstMonth && percentageChange !== null && (
|
{!isFirstMonth && percentageChange !== null && (
|
||||||
<div
|
<div
|
||||||
@@ -42,5 +51,31 @@ export function CategoryCell({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="font-medium">{formatCurrency(value)}</div>
|
||||||
|
{!isFirstMonth && absoluteChange !== null && (
|
||||||
|
<>
|
||||||
|
<div className="font-bold">
|
||||||
|
Mês anterior: {formatCurrency(previousValue)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
isIncrease && "text-red-500",
|
||||||
|
isDecrease && "text-green-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Diferença:{" "}
|
||||||
|
{absoluteChange >= 0
|
||||||
|
? `+${formatCurrency(absoluteChange)}`
|
||||||
|
: formatCurrency(absoluteChange)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TypeBadge } from "@/components/type-badge";
|
import Link from "next/link";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { CategoryReportData } from "@/lib/relatorios/types";
|
import type {
|
||||||
|
CategoryReportData,
|
||||||
|
CategoryReportItem,
|
||||||
|
} from "@/lib/relatorios/types";
|
||||||
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
|
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
|
||||||
import { getIconComponent } from "@/lib/utils/icons";
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
import { CategoryCell } from "./category-cell";
|
import { CategoryCell } from "./category-cell";
|
||||||
|
|
||||||
interface CategoryReportCardsProps {
|
interface CategoryReportCardsProps {
|
||||||
data: CategoryReportData;
|
data: CategoryReportData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryReportCards({ data }: CategoryReportCardsProps) {
|
interface CategoryCardProps {
|
||||||
const { categories, periods } = data;
|
category: CategoryReportItem;
|
||||||
|
periods: string[];
|
||||||
|
colorIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryCard({ category, periods, colorIndex }: CategoryCardProps) {
|
||||||
|
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:hidden space-y-4">
|
<Card>
|
||||||
{categories.map((category) => {
|
<CardHeader className="pb-3">
|
||||||
const Icon = category.icon ? getIconComponent(category.icon) : null;
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<CategoryIconBadge
|
||||||
return (
|
icon={category.icon}
|
||||||
<Card key={category.categoryId}>
|
name={category.name}
|
||||||
<CardHeader>
|
colorIndex={colorIndex}
|
||||||
<CardTitle className="flex items-center gap-2">
|
/>
|
||||||
{Icon && <Icon className="h-5 w-5 shrink-0" />}
|
<Link
|
||||||
<span className="flex-1 truncate">{category.name}</span>
|
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
|
||||||
<TypeBadge type={category.type} />
|
className="flex-1 truncate hover:underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</Link>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
@@ -57,7 +71,91 @@ export function CategoryReportCards({ data }: CategoryReportCardsProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
title: string;
|
||||||
|
categories: CategoryReportItem[];
|
||||||
|
periods: string[];
|
||||||
|
colorIndexOffset: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
categories,
|
||||||
|
periods,
|
||||||
|
colorIndexOffset,
|
||||||
|
total,
|
||||||
|
}: SectionProps) {
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatCurrency(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{categories.map((category, index) => (
|
||||||
|
<CategoryCard
|
||||||
|
key={category.categoryId}
|
||||||
|
category={category}
|
||||||
|
periods={periods}
|
||||||
|
colorIndex={colorIndexOffset + index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryReportCards({ data }: CategoryReportCardsProps) {
|
||||||
|
const { categories, periods } = data;
|
||||||
|
|
||||||
|
// Separate categories by type and calculate totals
|
||||||
|
const { receitas, despesas, receitasTotal, despesasTotal } = useMemo(() => {
|
||||||
|
const receitas: CategoryReportItem[] = [];
|
||||||
|
const despesas: CategoryReportItem[] = [];
|
||||||
|
let receitasTotal = 0;
|
||||||
|
let despesasTotal = 0;
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.type === "receita") {
|
||||||
|
receitas.push(category);
|
||||||
|
receitasTotal += category.total;
|
||||||
|
} else {
|
||||||
|
despesas.push(category);
|
||||||
|
despesasTotal += category.total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { receitas, despesas, receitasTotal, despesasTotal };
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden space-y-6">
|
||||||
|
{/* Despesas Section */}
|
||||||
|
<Section
|
||||||
|
title="Despesas"
|
||||||
|
categories={despesas}
|
||||||
|
periods={periods}
|
||||||
|
colorIndexOffset={0}
|
||||||
|
total={despesasTotal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Receitas Section */}
|
||||||
|
<Section
|
||||||
|
title="Receitas"
|
||||||
|
categories={receitas}
|
||||||
|
periods={periods}
|
||||||
|
colorIndexOffset={despesas.length}
|
||||||
|
total={receitasTotal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,49 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useMemo } from "react";
|
||||||
Table,
|
import type { CategoryReportData, CategoryReportItem } from "@/lib/relatorios/types";
|
||||||
TableBody,
|
import { CategoryTable } from "./category-table";
|
||||||
TableCell,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import type { CategoryReportData } from "@/lib/relatorios/types";
|
|
||||||
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
|
|
||||||
import { getIconComponent } from "@/lib/utils/icons";
|
|
||||||
import DotIcon from "../dot-icon";
|
|
||||||
import { Card } from "../ui/card";
|
|
||||||
import { CategoryCell } from "./category-cell";
|
|
||||||
|
|
||||||
interface CategoryReportTableProps {
|
interface CategoryReportTableProps {
|
||||||
data: CategoryReportData;
|
data: CategoryReportData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryReportTable({ data }: CategoryReportTableProps) {
|
export function CategoryReportTable({ data }: CategoryReportTableProps) {
|
||||||
const { categories, periods, totals, grandTotal } = data;
|
const { categories, periods } = data;
|
||||||
|
|
||||||
|
// Separate categories by type
|
||||||
|
const { receitas, despesas } = useMemo(() => {
|
||||||
|
const receitas: CategoryReportItem[] = [];
|
||||||
|
const despesas: CategoryReportItem[] = [];
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.type === "receita") {
|
||||||
|
receitas.push(category);
|
||||||
|
} else {
|
||||||
|
despesas.push(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { receitas, despesas };
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="px-6 py-4">
|
<div className="flex flex-col gap-6">
|
||||||
<Table>
|
{/* Despesas Table */}
|
||||||
<TableHeader>
|
<CategoryTable
|
||||||
<TableRow>
|
title="Despesas"
|
||||||
<TableHead className="w-[280px] min-w-[280px] font-bold">
|
categories={despesas}
|
||||||
Categoria
|
periods={periods}
|
||||||
</TableHead>
|
colorIndexOffset={0}
|
||||||
{periods.map((period) => (
|
|
||||||
<TableHead
|
|
||||||
key={period}
|
|
||||||
className="text-right min-w-[120px] font-bold"
|
|
||||||
>
|
|
||||||
{formatPeriodLabel(period)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
<TableHead className="text-right min-w-[120px] font-bold">
|
|
||||||
Total
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{categories.map((category) => {
|
|
||||||
const Icon = category.icon ? getIconComponent(category.icon) : null;
|
|
||||||
const isReceita = category.type.toLowerCase() === "receita";
|
|
||||||
const dotColor = isReceita
|
|
||||||
? "bg-green-600 dark:bg-green-400"
|
|
||||||
: "bg-red-600 dark:bg-red-400";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={category.categoryId}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<DotIcon bg_dot={dotColor} />
|
|
||||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
|
||||||
<span className="font-bold truncate">{category.name}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
{periods.map((period, periodIndex) => {
|
|
||||||
const monthData = category.monthlyData.get(period);
|
|
||||||
const isFirstMonth = periodIndex === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableCell key={period} className="text-right">
|
|
||||||
<CategoryCell
|
|
||||||
value={monthData?.amount ?? 0}
|
|
||||||
previousValue={monthData?.previousAmount ?? 0}
|
|
||||||
categoryType={category.type}
|
|
||||||
isFirstMonth={isFirstMonth}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<TableCell className="text-right font-semibold">
|
|
||||||
{formatCurrency(category.total)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
|
|
||||||
<TableFooter>
|
{/* Receitas Table */}
|
||||||
<TableRow>
|
<CategoryTable
|
||||||
<TableCell className="min-h-[2.5rem]">Total Geral</TableCell>
|
title="Receitas"
|
||||||
{periods.map((period) => {
|
categories={receitas}
|
||||||
const periodTotal = totals.get(period) ?? 0;
|
periods={periods}
|
||||||
return (
|
colorIndexOffset={despesas.length}
|
||||||
<TableCell
|
/>
|
||||||
key={period}
|
</div>
|
||||||
className="text-right font-semibold min-h-8"
|
|
||||||
>
|
|
||||||
{formatCurrency(periodTotal)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<TableCell className="text-right font-semibold min-h-8">
|
|
||||||
{formatCurrency(grandTotal)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
150
components/relatorios/category-table.tsx
Normal file
150
components/relatorios/category-table.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { CategoryReportItem } from "@/lib/relatorios/types";
|
||||||
|
import { formatCurrency, formatPeriodLabel } from "@/lib/relatorios/utils";
|
||||||
|
import { formatPeriodForUrl } from "@/lib/utils/period";
|
||||||
|
import DotIcon from "../dot-icon";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { CategoryCell } from "./category-cell";
|
||||||
|
|
||||||
|
export interface CategoryTableProps {
|
||||||
|
title: string;
|
||||||
|
categories: CategoryReportItem[];
|
||||||
|
periods: string[];
|
||||||
|
colorIndexOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryTable({
|
||||||
|
title,
|
||||||
|
categories,
|
||||||
|
periods,
|
||||||
|
colorIndexOffset,
|
||||||
|
}: CategoryTableProps) {
|
||||||
|
// Calculate section totals
|
||||||
|
const sectionTotals = useMemo(() => {
|
||||||
|
const totalsMap = new Map<string, number>();
|
||||||
|
let grandTotal = 0;
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
grandTotal += category.total;
|
||||||
|
for (const period of periods) {
|
||||||
|
const monthData = category.monthlyData.get(period);
|
||||||
|
const current = totalsMap.get(period) ?? 0;
|
||||||
|
totalsMap.set(period, current + (monthData?.amount ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalsMap, grandTotal };
|
||||||
|
}, [categories, periods]);
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="px-6 py-4">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[280px] min-w-[280px] font-bold">
|
||||||
|
Categoria
|
||||||
|
</TableHead>
|
||||||
|
{periods.map((period) => (
|
||||||
|
<TableHead
|
||||||
|
key={period}
|
||||||
|
className="text-right min-w-[120px] font-bold"
|
||||||
|
>
|
||||||
|
{formatPeriodLabel(period)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead className="text-right min-w-[120px] font-bold">
|
||||||
|
Total
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{categories.map((category, index) => {
|
||||||
|
const colorIndex = colorIndexOffset + index;
|
||||||
|
const periodParam = formatPeriodForUrl(periods[periods.length - 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={category.categoryId}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DotIcon
|
||||||
|
color={
|
||||||
|
category.type === "receita"
|
||||||
|
? "bg-green-600"
|
||||||
|
: "bg-red-600"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CategoryIconBadge
|
||||||
|
icon={category.icon}
|
||||||
|
name={category.name}
|
||||||
|
colorIndex={colorIndex}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href={`/categorias/${category.categoryId}?periodo=${periodParam}`}
|
||||||
|
className="flex items-center gap-1.5 truncate hover:underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{periods.map((period, periodIndex) => {
|
||||||
|
const monthData = category.monthlyData.get(period);
|
||||||
|
const isFirstMonth = periodIndex === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell key={period} className="text-right p-0">
|
||||||
|
<CategoryCell
|
||||||
|
value={monthData?.amount ?? 0}
|
||||||
|
previousValue={monthData?.previousAmount ?? 0}
|
||||||
|
categoryType={category.type}
|
||||||
|
isFirstMonth={isFirstMonth}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TableCell className="text-right font-semibold">
|
||||||
|
{formatCurrency(category.total)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-bold">Total</TableCell>
|
||||||
|
{periods.map((period) => {
|
||||||
|
const periodTotal = sectionTotals.totalsMap.get(period) ?? 0;
|
||||||
|
return (
|
||||||
|
<TableCell key={period} className="text-right font-semibold">
|
||||||
|
{formatCurrency(periodTotal)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TableCell className="text-right font-semibold">
|
||||||
|
{formatCurrency(sectionTotals.grandTotal)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export { CategoryReportExport } from "./category-report-export";
|
|||||||
export { CategoryReportFilters } from "./category-report-filters";
|
export { CategoryReportFilters } from "./category-report-filters";
|
||||||
export { CategoryReportPage } from "./category-report-page";
|
export { CategoryReportPage } from "./category-report-page";
|
||||||
export { CategoryReportTable } from "./category-report-table";
|
export { CategoryReportTable } from "./category-report-table";
|
||||||
|
export { CategoryTable } from "./category-table";
|
||||||
export type {
|
export type {
|
||||||
CategoryOption,
|
CategoryOption,
|
||||||
CategoryReportFiltersProps,
|
CategoryReportFiltersProps,
|
||||||
|
|||||||
@@ -184,12 +184,12 @@ export function createSidebarNavData(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Tendências",
|
title: "Tendências",
|
||||||
url: "/relatorios/categorias",
|
url: "/relatorios/tendencias",
|
||||||
icon: RiFileChartLine,
|
icon: RiFileChartLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Uso de Cartões",
|
title: "Uso de Cartões",
|
||||||
url: "/relatorios/cartoes",
|
url: "/relatorios/uso-cartoes",
|
||||||
icon: RiBankCard2Line,
|
icon: RiBankCard2Line,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiPriceTag3Line } from "@remixicon/react";
|
import { RiPriceTag3Line } from "@remixicon/react";
|
||||||
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/money-values";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
||||||
import type { TopEstabelecimentosData } from "@/lib/top-estabelecimentos/fetch-data";
|
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 { title_font } from "@/public/fonts/font_index";
|
||||||
import { Progress } from "../ui/progress";
|
import { Progress } from "../ui/progress";
|
||||||
|
|
||||||
@@ -56,12 +51,6 @@ export function TopCategories({ categories }: TopCategoriesProps) {
|
|||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{categories.map((category, index) => {
|
{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 =
|
const percent =
|
||||||
totalAmount > 0 ? (category.totalAmount / totalAmount) * 100 : 0;
|
totalAmount > 0 ? (category.totalAmount / totalAmount) * 100 : 0;
|
||||||
|
|
||||||
@@ -72,21 +61,11 @@ export function TopCategories({ categories }: TopCategoriesProps) {
|
|||||||
>
|
>
|
||||||
<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
|
<CategoryIconBadge
|
||||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
icon={category.icon}
|
||||||
style={{ backgroundColor: bgColor }}
|
name={category.name}
|
||||||
>
|
colorIndex={index}
|
||||||
{IconComponent ? (
|
/>
|
||||||
<IconComponent className="size-4" style={{ color }} />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="text-xs font-semibold uppercase"
|
|
||||||
style={{ color }}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name and percentage */}
|
{/* Name and percentage */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -110,7 +89,7 @@ export function TopCategories({ categories }: TopCategoriesProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="ml-12 mt-1.5">
|
<div className="ml-11 mt-1.5">
|
||||||
<Progress className="h-1.5" value={percent} />
|
<Progress className="h-1.5" value={percent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user