mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
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:
178
src/features/transactions/actions/refund-action.ts
Normal file
178
src/features/transactions/actions/refund-action.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user