chore: ajustes de componentes, estilos, dependências e métricas do dashboard

- dashboard: melhorias em métricas, filtros de transações e overview de período
- transactions: colunas, tabela e página com novos campos e ajustes de exibição
- ui: card, table, navigation-menu, navbar, month-picker, logo-picker, theme-toggler
- calculator: ajustes de display, keypad e estado
- calendar: melhorias de grid e day-cell
- insights: atualização de constantes
- settings: pequenos ajustes
- pnpm-lock: atualização de dependências
- pdf.worker: atualização do worker

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-02 22:08:53 +00:00
parent d55173e8c1
commit 94bf93194f
40 changed files with 4699 additions and 477 deletions

View File

@@ -0,0 +1,178 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { cards, categories, transactions } from "@/db/schema";
import {
buildRefundNote,
isRefundNote,
} from "@/shared/lib/accounts/constants";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PERIOD_FORMAT_REGEX } from "@/shared/lib/invoices";
import type { ActionResult } from "@/shared/lib/types/actions";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { parseLocalDateString } from "@/shared/utils/date";
import {
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
revalidate,
} from "./core";
const refundSchema = z.object({
originalTransactionId: z
.string({ message: "Lançamento inválido." })
.uuid("Lançamento inválido."),
refundDate: z
.string({ message: "Data inválida." })
.refine(
(value) => !Number.isNaN(parseLocalDateString(value).getTime()),
"Data inválida.",
),
refundPeriod: z
.string({ message: "Período inválido." })
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
});
type RefundInput = z.infer<typeof refundSchema>;
export async function refundTransactionAction(
input: RefundInput,
): Promise<ActionResult<{ refundId: string }>> {
try {
const user = await getUser();
const data = refundSchema.parse(input);
const original = await db.query.transactions.findFirst({
where: and(
eq(transactions.id, data.originalTransactionId),
eq(transactions.userId, user.id),
),
});
if (!original) {
return { success: false, error: "Lançamento não encontrado." };
}
if (original.transactionType !== "Despesa") {
return {
success: false,
error: "Apenas despesas podem ser estornadas.",
};
}
if (original.condition !== "À vista") {
return {
success: false,
error: "Apenas lançamentos à vista podem ser estornados.",
};
}
if (original.splitGroupId) {
return {
success: false,
error: "Lançamentos divididos não podem ser estornados.",
};
}
if (isRefundNote(original.note)) {
return {
success: false,
error: "Este lançamento já é um reembolso.",
};
}
const [existingRefund, card, paidPeriods, refundCategory] =
await Promise.all([
db.query.transactions.findFirst({
columns: { id: true },
where: and(
eq(transactions.userId, user.id),
eq(transactions.note, buildRefundNote(original.id)),
),
}),
original.cardId
? db.query.cards.findFirst({
columns: { id: true },
where: and(
eq(cards.id, original.cardId),
eq(cards.userId, user.id),
),
})
: Promise.resolve(null),
original.cardId
? getPaidInvoicePeriods(user.id, original.cardId, [data.refundPeriod])
: Promise.resolve([] as string[]),
db.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.name, "Reembolso"),
),
}),
]);
if (existingRefund) {
return {
success: false,
error: "Este lançamento já foi estornado.",
};
}
if (original.cardId && !card) {
return { success: false, error: "Cartão não encontrado." };
}
if (paidPeriods.length > 0) {
return {
success: false,
error: `A fatura de ${formatPaidInvoicePeriods(
paidPeriods,
)} já está paga. Desfaça o pagamento antes de lançar o reembolso.`,
};
}
const amountAbs = Math.abs(Number(original.amount));
const refundDate = parseLocalDateString(data.refundDate);
const [inserted] = await db
.insert(transactions)
.values({
name: `Reembolso de: ${original.name}`,
condition: "À vista",
paymentMethod: original.paymentMethod,
note: buildRefundNote(original.id),
amount: formatDecimalForDbRequired(amountAbs),
purchaseDate: refundDate,
transactionType: "Receita",
period: data.refundPeriod,
isSettled: false,
userId: user.id,
cardId: original.cardId,
accountId: original.accountId,
categoryId: refundCategory?.id ?? null,
payerId: original.payerId,
})
.returning({ id: transactions.id });
revalidate(user.id);
return {
success: true,
message: "Reembolso registrado.",
data: { refundId: inserted?.id ?? "" },
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: error.issues[0]?.message ?? "Dados inválidos.",
};
}
return {
success: false,
error: error instanceof Error ? error.message : "Erro inesperado.",
};
}
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { refundTransactionAction } from "@/features/transactions/actions/refund-action";
import { deriveCreditCardPeriod } from "@/features/transactions/form-helpers";
import { formatDate } from "@/features/transactions/formatting-helpers";
import { PeriodPicker } from "@/shared/components/period-picker";
import { Button } from "@/shared/components/ui/button";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
import { derivePeriodFromDate, displayPeriod } from "@/shared/utils/period";
import type { SelectOption, TransactionItem } from "../types";
type RefundTransactionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
transaction: TransactionItem | null;
cardOptions: SelectOption[];
};
const todayIso = () => new Date().toISOString().split("T")[0] ?? "";
function deriveDefaultRefundPeriod(
refundDate: string,
transaction: TransactionItem | null,
card: SelectOption | null,
) {
if (transaction?.cardId) {
return deriveCreditCardPeriod(
refundDate,
card?.closingDay ?? null,
card?.dueDay ?? null,
);
}
return derivePeriodFromDate(refundDate);
}
export function RefundTransactionDialog({
open,
onOpenChange,
transaction,
cardOptions,
}: RefundTransactionDialogProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [refundDate, setRefundDate] = useState<string>(todayIso());
const [refundPeriod, setRefundPeriod] = useState<string>("");
const card = useMemo(() => {
if (!transaction?.cardId) return null;
return cardOptions.find((opt) => opt.value === transaction.cardId) ?? null;
}, [transaction?.cardId, cardOptions]);
useEffect(() => {
if (open) {
const today = todayIso();
setRefundDate(today);
setRefundPeriod(deriveDefaultRefundPeriod(today, transaction, card));
}
}, [open, transaction, card]);
const defaultPeriod = useMemo(
() => deriveDefaultRefundPeriod(refundDate, transaction, card),
[refundDate, transaction, card],
);
if (!transaction) return null;
const amountAbs = Math.abs(transaction.amount);
const periodLabel = refundPeriod ? displayPeriod(refundPeriod) : "—";
const destinationLabel = transaction.cardId
? `na fatura de ${periodLabel}`
: `no extrato de ${periodLabel}`;
const handleSubmit = () => {
if (!refundDate) {
toast.error("Informe a data do reembolso.");
return;
}
if (!refundPeriod) {
toast.error("Informe o período do reembolso.");
return;
}
startTransition(async () => {
const result = await refundTransactionAction({
originalTransactionId: transaction.id,
refundDate,
refundPeriod,
});
if (result.success) {
toast.success(result.message);
onOpenChange(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Registrar reembolso</DialogTitle>
<DialogDescription>
Será criado um lançamento de reembolso espelhando esta despesa. O
lançamento original será mantido.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<p className="font-medium text-foreground">{transaction.name}</p>
<p className="text-muted-foreground">
{formatCurrency(amountAbs)} {" "}
{formatDate(transaction.purchaseDate)} {" "}
{transaction.paymentMethod}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="refund-date">Data do reembolso</Label>
<DatePicker
id="refund-date"
value={refundDate}
onChange={(value) => {
if (!value) return;
setRefundDate(value);
setRefundPeriod(
deriveDefaultRefundPeriod(value, transaction, card),
);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="refund-period">
{transaction.cardId
? "Fatura do reembolso"
: "Período do reembolso"}
</Label>
<PeriodPicker
value={refundPeriod || defaultPeriod}
onChange={setRefundPeriod}
disabled={isPending}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
O reembolso será lançado {destinationLabel}.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSubmit} disabled={isPending}>
{isPending ? "Registrando..." : "Registrar reembolso"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -33,6 +33,7 @@ import {
MassAddDialog,
type MassAddFormData,
} from "../dialogs/mass-add-dialog";
import { RefundTransactionDialog } from "../dialogs/refund-transaction-dialog";
import {
SplitPairDialog,
type SplitPairScope,
@@ -183,6 +184,9 @@ export function TransactionsPage({
const [transactionsToImport, setTransactionsToImport] = useState<
TransactionItem[]
>([]);
const [refundOpen, setRefundOpen] = useState(false);
const [transactionToRefund, setTransactionToRefund] =
useState<TransactionItem | null>(null);
const handleToggleSettlement = async (item: TransactionItem) => {
if (item.paymentMethod === "Cartão de crédito") {
@@ -539,6 +543,11 @@ export function TransactionsPage({
setDetailsOpen(true);
};
const handleRefund = (item: TransactionItem) => {
setTransactionToRefund(item);
setRefundOpen(true);
};
const handleAnticipate = (item: TransactionItem) => {
setSelectedForAnticipation(item);
setAnticipateOpen(true);
@@ -571,6 +580,7 @@ export function TransactionsPage({
onBulkDelete={handleMultipleBulkDelete}
onBulkImport={handleBulkImport}
onViewDetails={handleViewDetails}
onRefund={handleRefund}
onToggleSettlement={handleToggleSettlement}
onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory}
@@ -683,6 +693,18 @@ export function TransactionsPage({
onEdit={handleEdit}
/>
<RefundTransactionDialog
open={refundOpen && !!transactionToRefund}
onOpenChange={(open) => {
setRefundOpen(open);
if (!open) {
setTransactionToRefund(null);
}
}}
transaction={transactionToRefund}
cardOptions={cardOptions}
/>
<ConfirmActionDialog
open={deleteOpen && !!transactionToDelete}
onOpenChange={setDeleteOpen}

View File

@@ -12,6 +12,7 @@ import {
RiHistoryLine,
RiMoreFill,
RiPencilLine,
RiRefund2Line,
RiTimeLine,
} from "@remixicon/react";
import type { ColumnDef } from "@tanstack/react-table";
@@ -45,6 +46,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { formatDate } from "@/shared/utils/date";
@@ -60,6 +62,7 @@ export type BuildColumnsArgs = {
onImport?: (item: TransactionItem) => void;
onConfirmDelete?: (item: TransactionItem) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
@@ -121,6 +124,7 @@ function buildColumns({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -133,6 +137,7 @@ function buildColumns({
const handleImport = onImport ?? noop;
const handleConfirmDelete = onConfirmDelete ?? noop;
const handleViewDetails = onViewDetails ?? noop;
const handleRefund = onRefund ?? noop;
const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
@@ -682,6 +687,25 @@ function buildColumns({
Importar para Minha Conta
</DropdownMenuItem>
)}
{(() => {
const item = row.original;
const canRefund =
item.userId === currentUserId &&
item.transactionType === "Despesa" &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.readonly &&
!item.note?.startsWith(REFUND_NOTE_PREFIX);
if (!canRefund) return null;
return (
<DropdownMenuItem onSelect={() => handleRefund(item)}>
<RiRefund2Line className="size-4" />
Reembolso
</DropdownMenuItem>
);
})()}
{row.original.userId === currentUserId && (
<DropdownMenuItem
variant="destructive"

View File

@@ -70,6 +70,7 @@ type LancamentosTableProps = {
onBulkDelete?: (items: TransactionItem[]) => void;
onBulkImport?: (items: TransactionItem[]) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
@@ -98,6 +99,7 @@ export function TransactionsTable({
onBulkDelete,
onBulkImport,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -131,6 +133,7 @@ export function TransactionsTable({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
@@ -147,6 +150,7 @@ export function TransactionsTable({
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,