mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(lancamentos): aprimora antecipacao de parcelas
This commit is contained in:
@@ -26,6 +26,7 @@ import type {
|
||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||
import { comparePeriods } from "@/shared/utils/period";
|
||||
|
||||
/**
|
||||
* Schema de validação para criar antecipação
|
||||
@@ -63,14 +64,18 @@ const cancelAnticipationSchema = z.object({
|
||||
*/
|
||||
export async function getEligibleInstallmentsAction(
|
||||
seriesId: string,
|
||||
anticipationPeriod: string,
|
||||
): Promise<ActionResult<EligibleInstallment[]>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Validar seriesId
|
||||
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
|
||||
const validatedAnticipationPeriod =
|
||||
createAnticipationSchema.shape.anticipationPeriod.parse(
|
||||
anticipationPeriod,
|
||||
);
|
||||
|
||||
// Buscar todas as parcelas da série que estão elegíveis
|
||||
const rows = await db.query.transactions.findMany({
|
||||
where: and(
|
||||
eq(transactions.seriesId, validatedSeriesId),
|
||||
@@ -96,19 +101,23 @@ export async function getEligibleInstallmentsAction(
|
||||
},
|
||||
});
|
||||
|
||||
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: row.amount,
|
||||
period: row.period,
|
||||
purchaseDate: row.purchaseDate,
|
||||
dueDate: row.dueDate,
|
||||
currentInstallment: row.currentInstallment,
|
||||
installmentCount: row.installmentCount,
|
||||
paymentMethod: row.paymentMethod,
|
||||
categoryId: row.categoryId,
|
||||
payerId: row.payerId,
|
||||
}));
|
||||
const eligibleInstallments: EligibleInstallment[] = rows
|
||||
.filter(
|
||||
(row) => comparePeriods(row.period, validatedAnticipationPeriod) > 0,
|
||||
)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: row.amount,
|
||||
period: row.period,
|
||||
purchaseDate: row.purchaseDate,
|
||||
dueDate: row.dueDate,
|
||||
currentInstallment: row.currentInstallment,
|
||||
installmentCount: row.installmentCount,
|
||||
paymentMethod: row.paymentMethod,
|
||||
categoryId: row.categoryId,
|
||||
payerId: row.payerId,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -195,6 +204,18 @@ export async function createInstallmentAnticipationAction(
|
||||
};
|
||||
}
|
||||
|
||||
const selectedIncludesCurrentOrPastPeriod = installments.some(
|
||||
(installment) =>
|
||||
comparePeriods(installment.period, data.anticipationPeriod) <= 0,
|
||||
);
|
||||
|
||||
if (selectedIncludesCurrentOrPastPeriod) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Selecione apenas parcelas de períodos futuros para antecipar.",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Calcular valor total
|
||||
const totalAmountCents = installments.reduce(
|
||||
(sum, inst) => sum + Number(inst.amount) * 100,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
createInstallmentAnticipationAction,
|
||||
getEligibleInstallmentsAction,
|
||||
} from "@/features/transactions/actions/anticipation";
|
||||
import { installmentAnticipationsQueryKey } from "@/features/transactions/hooks/use-installment-anticipations";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
@@ -70,6 +72,7 @@ export function AnticipateInstallmentsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AnticipateInstallmentsDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
||||
@@ -86,7 +89,7 @@ export function AnticipateInstallmentsDialog({
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, replaceForm, updateField } =
|
||||
const { formState, replaceForm, updateField, updateFields } =
|
||||
useFormState<AnticipationFormValues>({
|
||||
anticipationPeriod: defaultPeriod,
|
||||
discount: "0",
|
||||
@@ -95,15 +98,34 @@ export function AnticipateInstallmentsDialog({
|
||||
note: "",
|
||||
});
|
||||
|
||||
// Buscar parcelas elegíveis ao abrir o dialog
|
||||
// Resetar formulário ao abrir o dialog
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setSelectedIds([]);
|
||||
setErrorMessage(null);
|
||||
replaceForm({
|
||||
anticipationPeriod: defaultPeriod,
|
||||
discount: "0",
|
||||
payerId: "",
|
||||
categoryId: "",
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
}, [defaultPeriod, dialogOpen, replaceForm]);
|
||||
|
||||
// Buscar parcelas elegíveis ao abrir o dialog e ao trocar o período
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
let shouldUpdate = true;
|
||||
|
||||
setIsLoadingInstallments(true);
|
||||
setSelectedIds([]);
|
||||
setErrorMessage(null);
|
||||
|
||||
getEligibleInstallmentsAction(seriesId)
|
||||
getEligibleInstallmentsAction(seriesId, formState.anticipationPeriod)
|
||||
.then((result) => {
|
||||
if (!shouldUpdate) return;
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "Erro ao carregar parcelas");
|
||||
setEligibleInstallments([]);
|
||||
@@ -116,25 +138,30 @@ export function AnticipateInstallmentsDialog({
|
||||
// Pré-preencher pagador e categoria da primeira parcela
|
||||
if (installments.length > 0) {
|
||||
const first = installments[0];
|
||||
replaceForm({
|
||||
anticipationPeriod: defaultPeriod,
|
||||
discount: "0",
|
||||
updateFields({
|
||||
payerId: first.payerId ?? "",
|
||||
categoryId: first.categoryId ?? "",
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!shouldUpdate) return;
|
||||
|
||||
console.error("Erro ao buscar parcelas:", error);
|
||||
toast.error("Erro ao carregar parcelas elegíveis");
|
||||
setEligibleInstallments([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!shouldUpdate) return;
|
||||
|
||||
setIsLoadingInstallments(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
shouldUpdate = false;
|
||||
};
|
||||
}
|
||||
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
|
||||
}, [dialogOpen, formState.anticipationPeriod, seriesId, updateFields]);
|
||||
|
||||
const totalAmount = useMemo(() => {
|
||||
return eligibleInstallments
|
||||
@@ -189,6 +216,9 @@ export function AnticipateInstallmentsDialog({
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||
});
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
const errorMsg = result.error || "Erro ao criar antecipação";
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
|
||||
import {
|
||||
installmentAnticipationsQueryKey,
|
||||
useInstallmentAnticipations,
|
||||
} from "@/features/transactions/hooks/use-installment-anticipations";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -31,7 +36,6 @@ interface AnticipationHistoryDialogProps {
|
||||
lancamentoName: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onViewLancamento?: (transactionId: string) => void;
|
||||
}
|
||||
|
||||
export function AnticipationHistoryDialog({
|
||||
@@ -40,7 +44,6 @@ export function AnticipationHistoryDialog({
|
||||
lancamentoName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onViewLancamento,
|
||||
}: AnticipationHistoryDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
@@ -51,87 +54,152 @@ export function AnticipationHistoryDialog({
|
||||
const {
|
||||
data: anticipations = [],
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
||||
|
||||
const handleCanceled = () => {
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
void refetch();
|
||||
}
|
||||
}, [dialogOpen, refetch]);
|
||||
|
||||
const cancelableAnticipation = anticipations.find(
|
||||
(anticipation) => anticipation.transaction?.isSettled !== true,
|
||||
);
|
||||
const anticipationCountLabel =
|
||||
anticipations.length === 1
|
||||
? "1 registro de antecipação encontrada"
|
||||
: `${anticipations.length} registros de antecipações encontradas`;
|
||||
|
||||
const refreshHistory = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelAnticipation = async () => {
|
||||
if (!cancelableAnticipation) return;
|
||||
|
||||
const result = await cancelInstallmentAnticipationAction({
|
||||
anticipationId: cancelableAnticipation.id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
refreshHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error || "Erro ao cancelar antecipação");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
|
||||
<DialogHeader>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
||||
<DialogDescription>{lancamentoName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
Carregando histórico...
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||
{isLoading || isFetching ? (
|
||||
<LoadingState />
|
||||
) : isError ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Não foi possível carregar</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
O histórico de antecipações não pôde ser carregado agora.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mx-auto"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</Empty>
|
||||
<ErrorState onRetry={() => void refetch()} />
|
||||
) : anticipations.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
As antecipações realizadas para esta compra parcelada
|
||||
aparecerão aqui.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
<EmptyState />
|
||||
) : (
|
||||
anticipations.map((anticipation) => (
|
||||
<AnticipationCard
|
||||
key={anticipation.id}
|
||||
anticipation={anticipation}
|
||||
onViewLancamento={onViewLancamento}
|
||||
onCanceled={handleCanceled}
|
||||
/>
|
||||
))
|
||||
<div className="min-w-0 space-y-3">
|
||||
<p className="text-left text-muted-foreground text-primary">
|
||||
{anticipationCountLabel}
|
||||
</p>
|
||||
{anticipations.map((anticipation) => (
|
||||
<AnticipationCard
|
||||
key={anticipation.id}
|
||||
anticipation={anticipation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && anticipations.length > 0 && (
|
||||
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
|
||||
{anticipations.length}{" "}
|
||||
{anticipations.length === 1
|
||||
? "antecipação encontrada"
|
||||
: "antecipações encontradas"}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{cancelableAnticipation ? (
|
||||
<ConfirmActionDialog
|
||||
trigger={
|
||||
<Button type="button" variant="destructive">
|
||||
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={handleCancelAnticipation}
|
||||
/>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed">
|
||||
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
Carregando histórico...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Não foi possível carregar</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
O histórico de antecipações não pôde ser carregado agora.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mx-auto"
|
||||
onClick={onRetry}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
As antecipações realizadas para esta compra parcelada aparecerão aqui.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export function InstallmentSelectionTable({
|
||||
Nenhuma parcela elegível para antecipação encontrada.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Todas as parcelas desta compra já foram pagas ou antecipadas.
|
||||
Apenas parcelas futuras, ainda não pagas ou antecipadas, aparecem
|
||||
aqui.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -851,16 +851,6 @@ export function TransactionsPage({
|
||||
onOpenChange={setAnticipationHistoryOpen}
|
||||
seriesId={selectedForAnticipation.seriesId as string}
|
||||
lancamentoName={selectedForAnticipation.name}
|
||||
onViewLancamento={(transactionId) => {
|
||||
const transaction = transactionList.find(
|
||||
(l) => l.id === transactionId,
|
||||
);
|
||||
if (transaction) {
|
||||
setSelectedTransaction(transaction);
|
||||
setDetailsOpen(true);
|
||||
setAnticipationHistoryOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user