feat(pagadores): adicionar widget no dashboard e atualizar avatares

- Novo widget de pagadores no dashboard com resumo de transações
- Substituir avatares SVG por PNG com melhor qualidade
- Melhorar seção de pagador no diálogo de lançamentos
- Adicionar ação para buscar pagadores por nome
- Atualizar componentes de seleção (cartões, categorias, contas)
- Melhorias no layout de ajustes e relatórios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-04 01:44:50 +00:00
parent 76702d770f
commit a70a83dd9d
61 changed files with 509 additions and 148 deletions

View File

@@ -1,6 +1,5 @@
"use client";
import type { ReactNode } from "react";
import {
RiAndroidLine,
RiDownload2Line,
@@ -9,6 +8,7 @@ import {
RiQrCodeLine,
RiShieldCheckLine,
} from "@remixicon/react";
import type { ReactNode } from "react";
import { Card } from "@/components/ui/card";
import { ApiTokensForm } from "./api-tokens-form";

View File

@@ -56,7 +56,7 @@ export function StatusSelectContent({ label }: { label: string }) {
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
color={
isActive
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-slate-400 dark:bg-slate-500"

View File

@@ -2,9 +2,9 @@ import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import type { CategoryType } from "@/lib/categorias/constants";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { cn } from "@/lib/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
import { TypeBadge } from "../type-badge";
import { Card } from "../ui/card";
import { CategoryIconBadge } from "./category-icon-badge";
type CategorySummary = {
id: string;

View File

@@ -8,7 +8,7 @@ export function TypeSelectContent({ label }: { label: string }) {
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
color={
isReceita
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-rose-600 dark:bg-rose-300"

View File

@@ -8,7 +8,7 @@ export function StatusSelectContent({ label }: { label: string }) {
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
color={
isActive
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-slate-400 dark:bg-slate-500"

View File

@@ -115,13 +115,24 @@ export function DashboardGridEditable({
}
}, []);
const handleToggleWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) =>
prev.includes(widgetId)
? prev.filter((id) => id !== widgetId)
: [...prev, widgetId],
);
}, []);
const handleToggleWidget = useCallback(
(widgetId: string) => {
const newHidden = hiddenWidgets.includes(widgetId)
? hiddenWidgets.filter((id) => id !== widgetId)
: [...hiddenWidgets, widgetId];
setHiddenWidgets(newHidden);
// Salvar automaticamente ao toggle
startTransition(async () => {
await updateWidgetPreferences({
order: widgetOrder,
hidden: newHidden,
});
});
},
[hiddenWidgets, widgetOrder],
);
const handleHideWidget = useCallback((widgetId: string) => {
setHiddenWidgets((prev) => [...prev, widgetId]);

View File

@@ -0,0 +1,96 @@
"use client";
import {
RiExternalLinkLine,
RiGroupLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import Link from "next/link";
import MoneyValues from "@/components/money-values";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CardContent } from "@/components/ui/card";
import type { DashboardPagador } from "@/lib/dashboard/pagadores";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { WidgetEmptyState } from "../widget-empty-state";
type PagadoresWidgetProps = {
pagadores: DashboardPagador[];
};
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "??";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "??";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "??";
};
export function PagadoresWidget({ pagadores }: PagadoresWidgetProps) {
return (
<CardContent className="flex flex-col gap-4 px-0">
{pagadores.length === 0 ? (
<WidgetEmptyState
icon={<RiGroupLine className="size-6 text-muted-foreground" />}
title="Nenhum pagador para o período"
description="Quando houver despesas associadas a pagadores, eles aparecerão aqui."
/>
) : (
<ul className="flex flex-col">
{pagadores.map((pagador) => {
const initials = buildInitials(pagador.name);
return (
<li
key={pagador.id}
className="flex items-center justify-between border-b border-dashed last:border-b-0 last:pb-0"
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-2">
<Avatar className="size-10 shrink-0">
<AvatarImage
src={getAvatarSrc(pagador.avatarUrl)}
alt={`Avatar de ${pagador.name}`}
/>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<Link
prefetch
href={`/pagadores/${pagador.id}`}
className="inline-flex max-w-full items-center gap-1 text-sm text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{pagador.name}</span>
{pagador.isAdmin && (
<RiVerifiedBadgeFill
className="size-4 shrink-0 text-blue-500"
aria-hidden
/>
)}
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
<p className="truncate text-xs text-muted-foreground">
{pagador.email ?? "Sem email cadastrado"}
</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={pagador.totalExpenses} />
</div>
</li>
);
})}
</ul>
)}
</CardContent>
);
}

View File

@@ -73,6 +73,7 @@ export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
export interface PagadorSectionProps extends BaseFieldSectionProps {
pagadorOptions: SelectOption[];
secondaryPagadorOptions: SelectOption[];
totalAmount: number;
}
export interface PaymentMethodSectionProps extends BaseFieldSectionProps {

View File

@@ -141,6 +141,11 @@ export function LancamentoDialog({
return groupAndSortCategorias(filtered);
}, [categoriaOptions, formState.transactionType]);
const totalAmount = useMemo(() => {
const parsed = Number.parseFloat(formState.amount);
return Number.isNaN(parsed) ? 0 : Math.abs(parsed);
}, [formState.amount]);
const handleFieldChange = useCallback(
<Key extends keyof FormState>(key: Key, value: FormState[Key]) => {
if (key === "period") {
@@ -223,6 +228,12 @@ export function LancamentoDialog({
? formState.secondaryPagadorId
: undefined,
isSplit: formState.isSplit,
primarySplitAmount: formState.isSplit
? Number.parseFloat(formState.primarySplitAmount) || undefined
: undefined,
secondarySplitAmount: formState.isSplit
? Number.parseFloat(formState.secondarySplitAmount) || undefined
: undefined,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
categoriaId: formState.categoriaId,
@@ -402,6 +413,7 @@ export function LancamentoDialog({
onFieldChange={handleFieldChange}
pagadorOptions={pagadorOptions}
secondaryPagadorOptions={secondaryPagadorOptions}
totalAmount={totalAmount}
/>
<PaymentMethodSection

View File

@@ -1,5 +1,7 @@
"use client";
import { useCallback } from "react";
import { CurrencyInput } from "@/components/ui/currency-input";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -16,63 +18,46 @@ export function PagadorSection({
onFieldChange,
pagadorOptions,
secondaryPagadorOptions,
totalAmount,
}: PagadorSectionProps) {
const handlePrimaryAmountChange = useCallback(
(value: string) => {
onFieldChange("primarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
},
[totalAmount, onFieldChange],
);
const handleSecondaryAmountChange = useCallback(
(value: string) => {
onFieldChange("secondarySplitAmount", value);
const numericValue = Number.parseFloat(value) || 0;
const remaining = Math.max(0, totalAmount - numericValue);
onFieldChange("primarySplitAmount", remaining.toFixed(2));
},
[totalAmount, onFieldChange],
);
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-full space-y-1">
<Label htmlFor="pagador">Pagador</Label>
<Select
value={formState.pagadorId}
onValueChange={(value) => onFieldChange("pagadorId", value)}
>
<SelectTrigger id="pagador" className="w-full">
<SelectValue placeholder="Selecione">
{formState.pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === formState.pagadorId,
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{pagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formState.isSplit ? (
<div className="w-full space-y-1">
<Label htmlFor="secondaryPagador">Dividir com</Label>
<div className="flex gap-2">
<Select
value={formState.secondaryPagadorId}
onValueChange={(value) =>
onFieldChange("secondaryPagadorId", value)
}
value={formState.pagadorId}
onValueChange={(value) => onFieldChange("pagadorId", value)}
>
<SelectTrigger
id="secondaryPagador"
disabled={secondaryPagadorOptions.length === 0}
className={"w-full"}
id="pagador"
className={formState.isSplit ? "w-[55%]" : "w-full"}
>
<SelectValue placeholder="Selecione">
{formState.secondaryPagadorId &&
{formState.pagadorId &&
(() => {
const selectedOption = secondaryPagadorOptions.find(
(opt) => opt.value === formState.secondaryPagadorId,
const selectedOption = pagadorOptions.find(
(opt) => opt.value === formState.pagadorId,
);
return selectedOption ? (
<PagadorSelectContent
@@ -84,7 +69,7 @@ export function PagadorSection({
</SelectValue>
</SelectTrigger>
<SelectContent>
{secondaryPagadorOptions.map((option) => (
{pagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
@@ -94,6 +79,65 @@ export function PagadorSection({
))}
</SelectContent>
</Select>
{formState.isSplit && (
<CurrencyInput
value={formState.primarySplitAmount}
onValueChange={handlePrimaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
)}
</div>
</div>
{formState.isSplit ? (
<div className="w-full space-y-1 mb-1">
<Label htmlFor="secondaryPagador">Dividir com</Label>
<div className="flex gap-2">
<Select
value={formState.secondaryPagadorId}
onValueChange={(value) =>
onFieldChange("secondaryPagadorId", value)
}
>
<SelectTrigger
id="secondaryPagador"
disabled={secondaryPagadorOptions.length === 0}
className="w-[55%]"
>
<SelectValue placeholder="Selecione">
{formState.secondaryPagadorId &&
(() => {
const selectedOption = secondaryPagadorOptions.find(
(opt) => opt.value === formState.secondaryPagadorId,
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{secondaryPagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<CurrencyInput
value={formState.secondarySplitAmount}
onValueChange={handleSecondaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
</div>
</div>
) : null}
</div>

View File

@@ -148,7 +148,7 @@ export function PagadorInfoCard({
alt={`Avatar de ${pagador.name}`}
width={64}
height={64}
className="h-full w-full object-cover"
className="h-full w-full object-cover rounded-full"
/>
</div>
@@ -214,7 +214,7 @@ export function PagadorInfoCard({
{pagador.email}
</Link>
) : (
""
"Sem e-mail cadastrado"
)
}
/>
@@ -260,7 +260,7 @@ export function PagadorInfoCard({
pagador.note ? (
<span className="text-muted-foreground">{pagador.note}</span>
) : (
""
"Sem observações"
)
}
className="sm:col-span-2"

View File

@@ -63,7 +63,7 @@ export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) {
<p className="mt-1 text-xs text-muted-foreground">{pagador.email}</p>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Sem Email cadastrado
Sem email cadastrado
</p>
)}

View File

@@ -1,5 +1,4 @@
"use client";
import { RiCheckLine, RiCloseCircleLine } from "@remixicon/react";
import Image from "next/image";
import {
useCallback,
@@ -33,7 +32,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import {
@@ -270,16 +268,9 @@ export function PagadorDialog({
</div>
</fieldset>
<fieldset className="flex flex-col gap-3">
<div className="grid grid-cols-4 gap-3 sm:grid-cols-5">
{availableAvatars.length === 0 ? (
<div className="col-span-5 flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 bg-muted/10 p-6 text-center text-sm text-muted-foreground">
<RiCloseCircleLine className="size-6" />
Nenhum avatar disponível. Adicione imagens em
<span className="font-mono text-xs">public/avatares</span>
.
</div>
) : null}
<fieldset className="flex flex-col gap-2">
<Label>Avatar</Label>
<div className="flex flex-wrap gap-3">
{availableAvatars.map((avatar) => {
const isSelected = avatar === formState.avatarUrl;
return (
@@ -287,22 +278,16 @@ export function PagadorDialog({
type="button"
key={avatar}
onClick={() => updateField("avatarUrl", avatar)}
className="group relative flex items-center justify-center overflow-hidden rounded-xl border border-border/70 p-2 transition-all hover:border-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 data-[selected=true]:border-primary data-[selected=true]:bg-primary/10"
className="group relative flex items-center justify-center rounded-full p-0.5 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 data-[selected=true]:ring-2 data-[selected=true]:ring-primary"
data-selected={isSelected}
aria-pressed={isSelected}
>
<span className="absolute inset-0 rounded-xl border-2 border-primary/80 opacity-0 transition-opacity group-data-[selected=true]:opacity-100" />
{isSelected ? (
<span className="absolute right-1 top-1 flex size-4 items-center justify-center rounded-full bg-sidebar-foreground text-primary-foreground shadow-sm">
<RiCheckLine className="size-3.5" />
</span>
) : null}
<Image
src={getAvatarSrc(avatar)}
alt={`Avatar ${avatar}`}
width={72}
height={72}
className="size-12 rounded-lg object-cover"
width={40}
height={40}
className="size-12 rounded-full object-cove hover:scale-110 transition-transform duration-200"
/>
</button>
);
@@ -312,12 +297,11 @@ export function PagadorDialog({
<div className="flex flex-col gap-2">
<Label htmlFor="pagador-note">Anotações</Label>
<Textarea
<Input
id="pagador-note"
rows={2}
value={formState.note}
onChange={(event) => updateField("note", event.target.value)}
placeholder="Observações, preferências ou detalhes relevantes sobre este pagador"
placeholder="Observações sobre este pagador"
/>
</div>
</div>

View File

@@ -8,7 +8,7 @@ export function StatusSelectContent({ label }: { label: string }) {
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
color={
isActive
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-slate-400 dark:bg-slate-500"

View File

@@ -9,6 +9,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState, useTransition } from "react";
import { EmptyState } from "@/components/empty-state";
import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { CategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import type { CategoryReportData } from "@/lib/relatorios/types";
@@ -123,36 +124,39 @@ export function CategoryReportPage({
{/* Empty States */}
{!isPending && hasNoCategories && (
<EmptyState
title="Nenhuma categoria cadastrada"
description="Você precisa cadastrar categorias antes de visualizar o relatório."
media={<RiPieChartLine className="h-12 w-12" />}
mediaVariant="icon"
/>
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
title="Nenhuma categoria cadastrada"
description="Você precisa cadastrar categorias antes de visualizar o relatório."
media={<RiPieChartLine className="size-6 text-primary" />}
/>
</Card>
)}
{!isPending &&
!hasNoCategories &&
hasNoData &&
filters.selectedCategories.length === 0 && (
<EmptyState
title="Selecione pelo menos uma categoria"
description="Use o filtro acima para selecionar as categorias que deseja visualizar no relatório."
media={<RiFilter3Line className="h-12 w-12" />}
mediaVariant="icon"
/>
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
title="Selecione pelo menos uma categoria"
description="Use o filtro acima para selecionar as categorias que deseja visualizar no relatório."
media={<RiFilter3Line className="size-6 text-primary" />}
/>
</Card>
)}
{!isPending &&
!hasNoCategories &&
hasNoData &&
filters.selectedCategories.length > 0 && (
<EmptyState
title="Nenhum lançamento encontrado"
description="Não há transações no período selecionado para as categorias filtradas."
media={<RiPieChartLine className="h-12 w-12" />}
mediaVariant="icon"
/>
<Card className="flex w-full items-center justify-center py-12">
<EmptyState
title="Nenhum lançamento encontrado"
description="Não há transações no período selecionado para as categorias filtradas."
media={<RiPieChartLine className="size-6 text-primary" />}
/>
</Card>
)}
{/* Tabs: Table and Chart */}

View File

@@ -24,10 +24,15 @@ export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
useSidebar();
const avatarSrc = useMemo(() => {
// Priorizar o avatar do pagador admin quando disponível
if (pagadorAvatarUrl) {
return getAvatarSrc(pagadorAvatarUrl);
}
// Fallback para a imagem do usuário (Google, etc)
if (user.image) {
return user.image;
}
return getAvatarSrc(pagadorAvatarUrl);
return getAvatarSrc(null);
}, [user.image, pagadorAvatarUrl]);
return (