From 8a19f0f311972de805fbe3b37291c6d46fd86b24 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sat, 23 May 2026 13:17:55 -0300 Subject: [PATCH] feat(lancamentos): aprimora antecipacao de parcelas --- .../transactions/actions/anticipation.ts | 49 +++- .../anticipate-installments-dialog.tsx | 46 +++- .../anticipation-history-dialog.tsx | 192 +++++++++----- .../installment-selection-table.tsx | 3 +- .../components/page/transactions-page.tsx | 10 - .../components/shared/anticipation-card.tsx | 251 +++++++----------- 6 files changed, 306 insertions(+), 245 deletions(-) diff --git a/src/features/transactions/actions/anticipation.ts b/src/features/transactions/actions/anticipation.ts index 0e2f55f..181b19a 100644 --- a/src/features/transactions/actions/anticipation.ts +++ b/src/features/transactions/actions/anticipation.ts @@ -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> { 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, diff --git a/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx b/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx index 2f270a7..134e44d 100644 --- a/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx +++ b/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx @@ -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(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({ 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"; diff --git a/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipation-history-dialog.tsx b/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipation-history-dialog.tsx index b26513d..068a888 100644 --- a/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipation-history-dialog.tsx +++ b/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipation-history-dialog.tsx @@ -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 ( - {trigger && {trigger}} - - + {trigger ? {trigger} : null} + + Histórico de Antecipações {lancamentoName} -
- {isLoading ? ( -
- - - Carregando histórico... - -
+
+ {isLoading || isFetching ? ( + ) : isError ? ( - - - - - - Não foi possível carregar - - O histórico de antecipações não pôde ser carregado agora. - - - - + void refetch()} /> ) : anticipations.length === 0 ? ( - - - - - - Nenhuma antecipação registrada - - As antecipações realizadas para esta compra parcelada - aparecerão aqui. - - - + ) : ( - anticipations.map((anticipation) => ( - - )) +
+

+ {anticipationCountLabel} +

+ {anticipations.map((anticipation) => ( + + ))} +
)}
- {!isLoading && anticipations.length > 0 && ( -
- {anticipations.length}{" "} - {anticipations.length === 1 - ? "antecipação encontrada" - : "antecipações encontradas"} -
- )} + + + + + {cancelableAnticipation ? ( + + Desfazer Antecipação + + } + 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} +
); } + +function LoadingState() { + return ( +
+ + + Carregando histórico... + +
+ ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( + + + + + + Não foi possível carregar + + O histórico de antecipações não pôde ser carregado agora. + + + + + ); +} + +function EmptyState() { + return ( + + + + + + Nenhuma antecipação registrada + + As antecipações realizadas para esta compra parcelada aparecerão aqui. + + + + ); +} diff --git a/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx b/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx index 35c113f..82f766d 100644 --- a/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx +++ b/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx @@ -49,7 +49,8 @@ export function InstallmentSelectionTable({ Nenhuma parcela elegível para antecipação encontrada.

- Todas as parcelas desta compra já foram pagas ou antecipadas. + Apenas parcelas futuras, ainda não pagas ou antecipadas, aparecem + aqui.

); diff --git a/src/features/transactions/components/page/transactions-page.tsx b/src/features/transactions/components/page/transactions-page.tsx index bb944d7..ee527a3 100644 --- a/src/features/transactions/components/page/transactions-page.tsx +++ b/src/features/transactions/components/page/transactions-page.tsx @@ -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); - } - }} /> )} diff --git a/src/features/transactions/components/shared/anticipation-card.tsx b/src/features/transactions/components/shared/anticipation-card.tsx index f640e3d..c062bba 100644 --- a/src/features/transactions/components/shared/anticipation-card.tsx +++ b/src/features/transactions/components/shared/anticipation-card.tsx @@ -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 ( - - -
- - {anticipation.installmentCount}{" "} - {anticipation.installmentCount === 1 - ? "parcela antecipada" - : "parcelas antecipadas"} - - - - {formatDate(anticipation.anticipationDate)} - + + +
+
+ + {anticipation.installmentCount}{" "} + {anticipation.installmentCount === 1 + ? "parcela antecipada" + : "parcelas antecipadas"} + + +
+ + {formatDate(anticipation.anticipationDate)} +
+
+ + + {displayPeriod(anticipation.anticipationPeriod)} + +
+ +
+ + {hasDiscount ? "Valor Final" : "Valor Total"} + + + + +
- - {displayPeriod(anticipation.anticipationPeriod)} -
- -
-
-
Valor Original
-
- -
-
+ +
+ + + - {Number(anticipation.discount) > 0 && ( -
-
Desconto
-
- - -
-
+ {hasDiscount ? ( + + - + + ) : ( +
)} -
0 - ? "col-span-2 border-t pt-3" - : "" - } - > -
- {Number(anticipation.discount) > 0 - ? "Valor Final" - : "Valor Total"} -
-
- -
-
+ + + {isSettled ? "Pago" : "Pendente"} + + -
-
Status do Lançamento
-
- - {isSettled ? "Pago" : "Pendente"} - -
-
- - {anticipation.payer && ( -
-
Pessoa
-
{anticipation.payer.name}
-
+ {anticipation.payer ? ( + {anticipation.payer.name} + ) : ( +
)} - {anticipation.category && ( -
-
Categoria
-
{anticipation.category.name}
-
- )} + {anticipation.category ? ( + + {anticipation.category.name} + + ) : null}
- {anticipation.note && ( -
-
+ {anticipation.note ? ( +
+

Observação -

-
{anticipation.note}
+

+

{anticipation.note}

- )} + ) : null}
- - - - - {canCancel && ( - - Desfazer Antecipação - - } - 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 && ( -
- Não é possível cancelar uma antecipação paga -
- )} -
); } + +function DetailItem({ + label, + children, + valueClassName, +}: { + label: string; + children: ReactNode; + valueClassName?: string; +}) { + return ( +
+
+ {label} +
+ +
+ {children} +
+
+ ); +}