feat(finance): refina fluxos de transacoes e pagadores

This commit is contained in:
Felipe Coutinho
2026-03-09 17:13:44 +00:00
parent 69da27276c
commit ada1377640
58 changed files with 1288 additions and 1559 deletions

View File

@@ -20,7 +20,10 @@ import {
PERIOD_FORMAT_REGEX,
} from "@/lib/faturas";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { parseLocalDateString } from "@/lib/utils/date";
import { getBusinessTodayDate, parseLocalDateString } from "@/lib/utils/date";
const isValidPaymentDate = (value: string) =>
!Number.isNaN(parseLocalDateString(value).getTime());
const updateInvoicePaymentStatusSchema = z.object({
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
@@ -30,7 +33,12 @@ const updateInvoicePaymentStatusSchema = z.object({
status: z.enum(
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
),
paymentDate: z.string().optional(),
paymentDate: z
.string()
.optional()
.refine((value) => !value || isValidPaymentDate(value), {
message: "Data de pagamento inválida.",
}),
});
type UpdateInvoicePaymentStatusInput = z.infer<
@@ -157,7 +165,7 @@ export async function updateInvoicePaymentStatusAction(
// Usar a data customizada ou a data atual como data de pagamento
const invoiceDate = data.paymentDate
? parseLocalDateString(data.paymentDate)
: new Date();
: getBusinessTodayDate();
const amount = `-${formatDecimal(adminShare)}`;
const payload = {
@@ -229,7 +237,11 @@ const updatePaymentDateSchema = z.object({
period: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
paymentDate: z.string({ message: "Data de pagamento inválida." }),
paymentDate: z
.string({ message: "Data de pagamento inválida." })
.refine((value) => isValidPaymentDate(value), {
message: "Data de pagamento inválida.",
}),
});
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;

View File

@@ -11,7 +11,6 @@ import {
pagadores,
} from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import { getUser } from "@/lib/auth/server";
import {
INITIAL_BALANCE_CONDITION,
@@ -30,8 +29,10 @@ import {
sendPagadorAutoEmails,
} from "@/lib/pagadores/notifications";
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
import type { ActionResult } from "@/lib/types/actions";
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
import { getTodayDate, parseLocalDateString } from "@/lib/utils/date";
import { getBusinessTodayDate, parseLocalDateString } from "@/lib/utils/date";
import { addMonthsToPeriod } from "@/lib/utils/period";
// ============================================================================
// Authorization Validation Functions
@@ -108,11 +109,14 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => {
return `${year}-${month}`;
};
const isValidDateInput = (value: string) =>
!Number.isNaN(parseLocalDateString(value).getTime());
const baseFields = z.object({
purchaseDate: z
.string({ message: "Informe a data da transação." })
.trim()
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
.refine((value) => isValidDateInput(value), {
message: "Data da transação inválida.",
}),
period: z
@@ -164,14 +168,14 @@ const baseFields = z.object({
dueDate: z
.string()
.trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de vencimento válida.",
})
.optional(),
boletoPaymentDate: z
.string()
.trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de pagamento válida.",
})
.optional(),
@@ -353,23 +357,6 @@ const splitAmount = (totalCents: number, parts: number) => {
);
};
const addMonthsToPeriod = (period: string, offset: number) => {
const [yearStr, monthStr] = period.split("-");
const baseYear = Number(yearStr);
const baseMonth = Number(monthStr);
if (!baseYear || !baseMonth) {
throw new Error("Período inválido.");
}
const date = new Date(baseYear, baseMonth - 1, 1);
date.setMonth(date.getMonth() + offset);
const nextYear = date.getFullYear();
const nextMonth = String(date.getMonth() + 1).padStart(2, "0");
return `${nextYear}-${nextMonth}`;
};
const addMonthsToDate = (value: Date, offset: number) => {
const result = new Date(value);
const originalDay = result.getDate();
@@ -648,7 +635,7 @@ export async function createLancamentoAction(
const boletoPaymentDate = shouldSetBoletoPaymentDate
? data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate)
: getTodayDate()
: getBusinessTodayDate()
: null;
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
@@ -828,7 +815,7 @@ export async function updateLancamentoAction(
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
? data.boletoPaymentDate
? parseLocalDateString(data.boletoPaymentDate)
: getTodayDate()
: getBusinessTodayDate()
: null;
await db
@@ -982,7 +969,7 @@ export async function toggleLancamentoSettlementAction(
const isBoleto = existing.paymentMethod === "Boleto";
const boletoPaymentDate = isBoleto
? data.value
? getTodayDate()
? getBusinessTodayDate()
: null
: null;
@@ -1118,7 +1105,7 @@ const updateBulkSchema = z.object({
dueDate: z
.string()
.trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de vencimento válida.",
})
.optional()
@@ -1126,7 +1113,7 @@ const updateBulkSchema = z.object({
boletoPaymentDate: z
.string()
.trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
.refine((value) => !value || isValidDateInput(value), {
message: "Informe uma data de pagamento válida.",
})
.optional()
@@ -1284,7 +1271,7 @@ export async function updateLancamentoBulkAction(
});
await applyUpdates(
futureLancamentos.map((item) => ({
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
@@ -1311,7 +1298,7 @@ export async function updateLancamentoBulkAction(
});
await applyUpdates(
allLancamentos.map((item) => ({
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
})),
@@ -1335,7 +1322,7 @@ const massAddTransactionSchema = z.object({
purchaseDate: z
.string({ message: "Informe a data da transação." })
.trim()
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
.refine((value) => isValidDateInput(value), {
message: "Data da transação inválida.",
}),
name: z
@@ -1598,12 +1585,12 @@ export async function deleteMultipleLancamentosAction(
const notificationData = existing
.filter(
(
item,
item: (typeof existing)[number],
): item is typeof item & {
pagadorId: NonNullable<typeof item.pagadorId>;
} => Boolean(item.pagadorId),
)
.map((item) => ({
.map((item: (typeof existing)[number]) => ({
pagadorId: item.pagadorId,
name: item.name ?? null,
amount: item.amount ?? null,
@@ -1662,11 +1649,11 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
// Remove duplicates and filter empty names
const uniqueNames = Array.from(
new Set(
new Set<string>(
results
.map((r) => r.name)
.map((r: (typeof results)[number]) => r.name)
.filter(
(name): name is string =>
(name: string | null): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"),

View File

@@ -9,7 +9,7 @@ import {
pagadores,
} from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import type { ActionResult } from "@/lib/types/actions";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {

View File

@@ -3,18 +3,16 @@
import { and, eq, ne } from "drizzle-orm";
import { z } from "zod";
import { categorias, orcamentos } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
import type { ActionResult } from "@/lib/types/actions";
import {
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/lib/utils/currency";
import { getPreviousPeriod } from "@/lib/utils/period";
const budgetBaseSchema = z.object({
categoriaId: uuidSchema("Categoria"),
@@ -43,9 +41,13 @@ const deleteBudgetSchema = z.object({
id: uuidSchema("Orçamento"),
});
type BudgetCreateInput = z.infer<typeof createBudgetSchema>;
type BudgetUpdateInput = z.infer<typeof updateBudgetSchema>;
type BudgetDeleteInput = z.infer<typeof deleteBudgetSchema>;
type BudgetCreateInput = z.input<typeof createBudgetSchema>;
type BudgetUpdateInput = z.input<typeof updateBudgetSchema>;
type BudgetDeleteInput = z.input<typeof deleteBudgetSchema>;
type BudgetCopyRow = {
categoriaId: string | null;
amount: unknown;
};
const ensureCategory = async (userId: string, categoriaId: string) => {
const category = await db.query.categorias.findFirst({
@@ -193,7 +195,7 @@ const duplicatePreviousMonthSchema = z.object({
period: periodSchema,
});
type DuplicatePreviousMonthInput = z.infer<typeof duplicatePreviousMonthSchema>;
type DuplicatePreviousMonthInput = z.input<typeof duplicatePreviousMonthSchema>;
export async function duplicatePreviousMonthBudgetsAction(
input: DuplicatePreviousMonthInput,
@@ -203,22 +205,15 @@ export async function duplicatePreviousMonthBudgetsAction(
const data = duplicatePreviousMonthSchema.parse(input);
// Calcular mês anterior
const [year, month] = data.period.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
const previousDate = new Date(currentDate);
previousDate.setMonth(previousDate.getMonth() - 1);
const prevYear = previousDate.getFullYear();
const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0");
const previousPeriod = `${prevYear}-${prevMonth}`;
const previousPeriod = getPreviousPeriod(data.period);
// Buscar orçamentos do mês anterior
const previousBudgets = await db.query.orcamentos.findMany({
const previousBudgets = (await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, previousPeriod),
),
});
})) as BudgetCopyRow[];
if (previousBudgets.length === 0) {
return {
@@ -228,12 +223,12 @@ export async function duplicatePreviousMonthBudgetsAction(
}
// Buscar orçamentos existentes do mês atual
const currentBudgets = await db.query.orcamentos.findMany({
const currentBudgets = (await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period),
),
});
})) as BudgetCopyRow[];
// Filtrar para evitar duplicatas
const existingCategoryIds = new Set(

View File

@@ -14,6 +14,8 @@ import {
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
} from "@/lib/pagadores/details";
import { formatCurrency } from "@/lib/utils/currency";
import { formatDateTime } from "@/lib/utils/date";
import { displayPeriod } from "@/lib/utils/period";
const inputSchema = z.object({
@@ -27,20 +29,14 @@ type ActionResult =
| { success: true; message: string }
| { success: false; error: string };
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
const formatDate = (value: Date | null | undefined) => {
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
return (
formatDateTime(value, {
day: "2-digit",
month: "short",
year: "numeric",
}) ?? "—"
);
};
// Escapa HTML para prevenir XSS
@@ -515,25 +511,47 @@ export async function sendPagadorSummaryAction(
.orderBy(desc(lancamentos.purchaseDate)),
]);
const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({
const normalizedBoletos: BoletoItem[] = (
boletoRows as Array<{
name: string | null;
amount: unknown;
dueDate: Date | null;
}>
).map((row) => ({
name: row.name ?? "Sem descrição",
amount: Math.abs(Number(row.amount ?? 0)),
dueDate: row.dueDate,
}));
const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map(
(row) => ({
id: row.id,
name: row.name,
paymentMethod: row.paymentMethod,
condition: row.condition,
transactionType: row.transactionType,
purchaseDate: row.purchaseDate,
amount: Number(row.amount ?? 0),
}),
);
const normalizedLancamentos: LancamentoRow[] = (
lancamentoRows as Array<{
id: string;
name: string | null;
paymentMethod: string | null;
condition: string | null;
transactionType: string | null;
purchaseDate: Date | null;
amount: unknown;
}>
).map((row) => ({
id: row.id,
name: row.name,
paymentMethod: row.paymentMethod,
condition: row.condition,
transactionType: row.transactionType,
purchaseDate: row.purchaseDate,
amount: Number(row.amount ?? 0),
}));
const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
const normalizedParcelados: ParceladoItem[] = (
parceladoRows as Array<{
name: string | null;
amount: unknown;
installmentCount: number | null;
currentInstallment: number | null;
purchaseDate: Date | null;
}>
).map((row) => {
const installmentAmount = Math.abs(Number(row.amount ?? 0));
const installmentCount = row.installmentCount ?? 1;
const totalAmount = installmentAmount * installmentCount;

View File

@@ -1,56 +1,47 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página de detalhes do pagador
* Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos)
* Loading state para a página de detalhes do pagador.
* Layout: navegação mensal + tabs com card compartilhado do pagador.
*/
export default function PagadorDetailsLoading() {
return (
<main className="flex flex-col gap-6">
{/* Month Picker placeholder */}
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
{/* Info do Pagador (sempre visível) */}
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
{/* Avatar */}
<Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3">
{/* Nome + Badge */}
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Email */}
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
{/* Status */}
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Botões de ação */}
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
{/* Tabs */}
<div className="space-y-6 pt-4">
<div className="flex gap-2 border-b">
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
<Skeleton className="h-10 w-36 rounded-t-2xl bg-foreground/10" />
</div>
<div className="rounded-2xl border p-6 space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-20 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
</div>
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
<div className="flex items-center gap-2">
<Skeleton className="size-2 rounded-full bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
{/* Conteúdo da aba Visão Geral (grid de cards) */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Card de resumo mensal */}
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
<div className="grid grid-cols-3 gap-4 pt-4">
@@ -63,7 +54,6 @@ export default function PagadorDetailsLoading() {
</div>
</div>
{/* Outros cards */}
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
<div className="flex items-center gap-2">

View File

@@ -6,6 +6,7 @@ import {
import { notFound } from "next/navigation";
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { ExpandableWidgetCard } from "@/components/shared/expandable-widget-card";
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
import type {
ContaCartaoFilterOption,
@@ -15,6 +16,7 @@ import type {
} from "@/components/lancamentos/types";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
import { PagadorHeaderCard } from "@/components/pagadores/details/pagador-header-card";
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
@@ -25,7 +27,6 @@ import {
} from "@/components/pagadores/details/pagador-payment-method-cards";
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import WidgetCard from "@/components/widget-card";
import type { pagadores } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import {
@@ -50,6 +51,7 @@ import {
fetchPagadorHistory,
fetchPagadorMonthlyBreakdown,
fetchPagadorPaymentStatus,
type PagadorCardUsageItem,
} from "@/lib/pagadores/details";
import { parsePeriodParam } from "@/lib/utils/period";
import {
@@ -232,6 +234,7 @@ export default async function Page({ params, searchParams }: PageProps) {
label: pagador.name,
slug: pagador.id,
role: pagador.role,
avatarUrl: pagador.avatarUrl,
},
],
categoriaFiltersRaw: [],
@@ -284,7 +287,7 @@ export default async function Page({ params, searchParams }: PageProps) {
periodLabel,
totalExpenses: monthlyBreakdown.totalExpenses,
paymentSplits: monthlyBreakdown.paymentSplits,
cardUsage: cardUsage.slice(0, 3).map((item) => ({
cardUsage: cardUsage.slice(0, 3).map((item: PagadorCardUsageItem) => ({
name: item.name,
amount: item.amount,
})),
@@ -308,15 +311,14 @@ export default async function Page({ params, searchParams }: PageProps) {
<TabsTrigger value="painel">Painel</TabsTrigger>
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
</TabsList>
<PagadorHeaderCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
<TabsContent value="profile" className="space-y-4">
<section>
<PagadorInfoCard
pagador={pagadorData}
selectedPeriod={selectedPeriod}
summary={summaryPreview}
/>
</section>
<PagadorInfoCard pagador={pagadorData} />
{canEdit && pagadorData.shareCode ? (
<PagadorSharingCard
pagadorId={pagador.id}
@@ -343,27 +345,27 @@ export default async function Page({ params, searchParams }: PageProps) {
</section>
<section className="grid gap-3 lg:grid-cols-3">
<WidgetCard
<ExpandableWidgetCard
title="Minhas Faturas"
subtitle="Valores por cartão neste período"
icon={<RiBankCard2Line className="size-4" />}
>
<PagadorCardUsageCard items={cardUsage} />
</WidgetCard>
<WidgetCard
</ExpandableWidgetCard>
<ExpandableWidgetCard
title="Boletos"
subtitle="Boletos registrados neste período"
icon={<RiBarcodeLine className="size-4" />}
>
<PagadorBoletoCard items={boletoItems} />
</WidgetCard>
<WidgetCard
</ExpandableWidgetCard>
<ExpandableWidgetCard
title="Status de Pagamento"
subtitle="Situação das despesas no período"
icon={<RiWallet3Line className="size-4" />}
>
<PagadorPaymentStatusCard data={paymentStatus} />
</WidgetCard>
</ExpandableWidgetCard>
</section>
</TabsContent>

View File

@@ -6,7 +6,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { compartilhamentosPagador, pagadores, user } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import type { ActionResult } from "@/lib/types/actions";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import {

View File

@@ -4,7 +4,7 @@ import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { preLancamentos } from "@/db/schema";
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
import type { ActionResult } from "@/lib/actions/types";
import type { ActionResult } from "@/lib/types/actions";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";