feat(lancamentos): aprimora antecipacao de parcelas

This commit is contained in:
Felipe Coutinho
2026-05-23 13:17:55 -03:00
parent 887885cd98
commit 8a19f0f311
6 changed files with 306 additions and 245 deletions

View File

@@ -3,19 +3,13 @@
import { RiCalendarCheckLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useTransition } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
import type { ReactNode } from "react";
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
@@ -23,172 +17,129 @@ import { displayPeriod } from "@/shared/utils/period";
interface AnticipationCardProps {
anticipation: InstallmentAnticipationListItem;
onViewLancamento?: (transactionId: string) => void;
onCanceled?: () => void;
}
export function AnticipationCard({
anticipation,
onViewLancamento,
onCanceled,
}: AnticipationCardProps) {
const [isPending, startTransition] = useTransition();
export function AnticipationCard({ anticipation }: AnticipationCardProps) {
const isSettled = anticipation.transaction?.isSettled === true;
const canCancel = !isSettled;
const totalAmount = Number(anticipation.totalAmount);
const discount = Number(anticipation.discount);
const finalAmount =
totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
const hasDiscount = discount > 0;
const formatDate = (date: string) => {
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
};
const handleCancel = async () => {
startTransition(async () => {
const result = await cancelInstallmentAnticipationAction({
anticipationId: anticipation.id,
});
if (result.success) {
toast.success(result.message);
onCanceled?.();
} else {
toast.error(result.error || "Erro ao cancelar antecipação");
}
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", {
locale: ptBR,
});
};
const handleViewLancamento = () => {
onViewLancamento?.(anticipation.transactionId);
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-base">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<CardDescription>
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
{formatDate(anticipation.anticipationDate)}
</CardDescription>
<Card className="shadow-none py-2">
<CardHeader className="space-y-3 p-4 pb-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<CardTitle className="text-base leading-none">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RiCalendarCheckLine className="size-3 shrink-0" />
<span>{formatDate(anticipation.anticipationDate)}</span>
</div>
</div>
<Badge variant="secondary" className="shrink-0 rounded-full px-3">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg bg-primary/10 p-3">
<span className="text-xs font-medium text-foreground">
{hasDiscount ? "Valor Final" : "Valor Total"}
</span>
<span className="text-lg font-semibold leading-none text-primary">
<MoneyValues amount={finalAmount} />
</span>
</div>
<Badge variant="secondary">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">Valor Original</dt>
<dd className="mt-1 font-medium">
<MoneyValues amount={Number(anticipation.totalAmount)} />
</dd>
</div>
<CardContent className="px-4 pb-4 pt-0">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<DetailItem label="Valor Original">
<MoneyValues amount={totalAmount} />
</DetailItem>
{Number(anticipation.discount) > 0 && (
<div>
<dt className="text-muted-foreground">Desconto</dt>
<dd className="mt-1 font-medium text-success">
- <MoneyValues amount={Number(anticipation.discount)} />
</dd>
</div>
{hasDiscount ? (
<DetailItem label="Desconto" valueClassName="text-success">
- <MoneyValues amount={discount} />
</DetailItem>
) : (
<div />
)}
<div
className={
Number(anticipation.discount) > 0
? "col-span-2 border-t pt-3"
: ""
}
>
<dt className="text-muted-foreground">
{Number(anticipation.discount) > 0
? "Valor Final"
: "Valor Total"}
</dt>
<dd className="mt-1 text-lg font-semibold text-primary">
<MoneyValues
amount={
Number(anticipation.totalAmount) < 0
? Number(anticipation.totalAmount) +
Number(anticipation.discount)
: Number(anticipation.totalAmount) -
Number(anticipation.discount)
}
/>
</dd>
</div>
<DetailItem label="Status">
<Badge
variant={isSettled ? "success" : "outline"}
className="h-5 rounded-full px-2 text-xs"
>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</DetailItem>
<div>
<dt className="text-muted-foreground">Status do Lançamento</dt>
<dd className="mt-1">
<Badge variant={isSettled ? "success" : "outline"}>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</dd>
</div>
{anticipation.payer && (
<div>
<dt className="text-muted-foreground">Pessoa</dt>
<dd className="mt-1 font-medium">{anticipation.payer.name}</dd>
</div>
{anticipation.payer ? (
<DetailItem label="Pessoa">{anticipation.payer.name}</DetailItem>
) : (
<div />
)}
{anticipation.category && (
<div>
<dt className="text-muted-foreground">Categoria</dt>
<dd className="mt-1 font-medium">{anticipation.category.name}</dd>
</div>
)}
{anticipation.category ? (
<DetailItem label="Categoria">
{anticipation.category.name}
</DetailItem>
) : null}
</dl>
{anticipation.note && (
<div className="rounded-lg border p-3">
<dt className="text-xs font-medium text-muted-foreground">
{anticipation.note ? (
<div className="mt-3 border-t pt-3">
<p className="text-xs font-medium text-muted-foreground">
Observação
</dt>
<dd className="mt-1 text-sm">{anticipation.note}</dd>
</p>
<p className="mt-1 text-sm leading-snug">{anticipation.note}</p>
</div>
)}
) : null}
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={handleViewLancamento}
disabled={isPending}
>
Cancelar
</Button>
{canCancel && (
<ConfirmActionDialog
trigger={
<Button variant="destructive" size="sm" disabled={isPending}>
Desfazer Antecipação
</Button>
}
title="Cancelar antecipação?"
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
confirmLabel="Cancelar Antecipação"
confirmVariant="destructive"
pendingLabel="Cancelando..."
onConfirm={handleCancel}
/>
)}
{isSettled && (
<div className="text-xs text-muted-foreground">
Não é possível cancelar uma antecipação paga
</div>
)}
</CardFooter>
</Card>
);
}
function DetailItem({
label,
children,
valueClassName,
}: {
label: string;
children: ReactNode;
valueClassName?: string;
}) {
return (
<div className="min-w-0 space-y-1">
<dt className="text-xs font-medium leading-none text-muted-foreground">
{label}
</dt>
<dd
className={`truncate text-sm font-medium leading-tight ${
valueClassName ?? ""
}`}
>
{children}
</dd>
</div>
);
}