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,4 +1,4 @@
import { RiSettingsLine } from "@remixicon/react";
import { RiSettings2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
@@ -13,7 +13,7 @@ export default function RootLayout({
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiSettingsLine />}
icon={<RiSettings2Line />}
title="Ajustes"
subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência."
/>

View File

@@ -143,6 +143,8 @@ const baseFields = z.object({
pagadorId: uuidSchema("Pagador").nullable().optional(),
secondaryPagadorId: uuidSchema("Pagador secundário").optional(),
isSplit: z.boolean().optional().default(false),
primarySplitAmount: z.coerce.number().min(0).optional(),
secondarySplitAmount: z.coerce.number().min(0).optional(),
contaId: uuidSchema("Conta").nullable().optional(),
cartaoId: uuidSchema("Cartão").nullable().optional(),
categoriaId: uuidSchema("Categoria").nullable().optional(),
@@ -234,6 +236,23 @@ const refineLancamento = (
message: "Escolha um pagador diferente para dividir o lançamento.",
});
}
// Validate custom split amounts sum to total
if (
data.primarySplitAmount !== undefined &&
data.secondarySplitAmount !== undefined
) {
const sum = data.primarySplitAmount + data.secondarySplitAmount;
const total = Math.abs(data.amount);
// Allow 1 cent tolerance for rounding differences
if (Math.abs(sum - total) > 0.01) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["primarySplitAmount"],
message: "A soma das divisões deve ser igual ao valor total.",
});
}
}
}
};
@@ -354,17 +373,33 @@ const buildShares = ({
pagadorId,
isSplit,
secondaryPagadorId,
primarySplitAmountCents,
secondarySplitAmountCents,
}: {
totalCents: number;
pagadorId: string | null;
isSplit: boolean;
secondaryPagadorId?: string;
primarySplitAmountCents?: number;
secondarySplitAmountCents?: number;
}): Share[] => {
if (isSplit) {
if (!pagadorId || !secondaryPagadorId) {
throw new Error("Configuração de divisão inválida para o lançamento.");
}
// Use custom split amounts if provided
if (
primarySplitAmountCents !== undefined &&
secondarySplitAmountCents !== undefined
) {
return [
{ pagadorId, amountCents: primarySplitAmountCents },
{ pagadorId: secondaryPagadorId, amountCents: secondarySplitAmountCents },
];
}
// Fallback to equal split
const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2);
return [
{ pagadorId, amountCents: primaryAmount },
@@ -598,6 +633,12 @@ export async function createLancamentoAction(
pagadorId: data.pagadorId ?? null,
isSplit: data.isSplit ?? false,
secondaryPagadorId: data.secondaryPagadorId,
primarySplitAmountCents: data.primarySplitAmount
? Math.round(data.primarySplitAmount * 100)
: undefined,
secondarySplitAmountCents: data.secondarySplitAmount
? Math.round(data.secondarySplitAmount * 100)
: undefined,
});
const isSeriesLancamento =

View File

@@ -1,7 +1,10 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { PagadoresPage } from "@/components/pagadores/pagadores-page";
import { user } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
import type { PagadorStatus } from "@/lib/pagadores/constants";
import {
@@ -44,11 +47,21 @@ const resolveStatus = (status: string | null): PagadorStatus => {
export default async function Page() {
const userId = await getUserId();
const [pagadorRows, avatarOptions] = await Promise.all([
const [pagadorRows, localAvatarOptions, userData] = await Promise.all([
fetchPagadoresWithAccess(userId),
loadAvatarOptions(),
db.query.user.findFirst({
columns: { image: true },
where: eq(user.id, userId),
}),
]);
// Incluir a imagem do Google nas opções se disponível
const userImage = userData?.image;
const avatarOptions = userImage
? [userImage, ...localAvatarOptions]
: localAvatarOptions;
const pagadoresData = pagadorRows
.map((pagador) => ({
id: pagador.id,