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 { uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||||
|
import { comparePeriods } from "@/shared/utils/period";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema de validação para criar antecipação
|
* Schema de validação para criar antecipação
|
||||||
@@ -63,14 +64,18 @@ const cancelAnticipationSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export async function getEligibleInstallmentsAction(
|
export async function getEligibleInstallmentsAction(
|
||||||
seriesId: string,
|
seriesId: string,
|
||||||
|
anticipationPeriod: string,
|
||||||
): Promise<ActionResult<EligibleInstallment[]>> {
|
): Promise<ActionResult<EligibleInstallment[]>> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
// Validar seriesId
|
// Validar seriesId
|
||||||
const validatedSeriesId = uuidSchema("Série").parse(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({
|
const rows = await db.query.transactions.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, validatedSeriesId),
|
eq(transactions.seriesId, validatedSeriesId),
|
||||||
@@ -96,7 +101,11 @@ export async function getEligibleInstallmentsAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
|
const eligibleInstallments: EligibleInstallment[] = rows
|
||||||
|
.filter(
|
||||||
|
(row) => comparePeriods(row.period, validatedAnticipationPeriod) > 0,
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
amount: row.amount,
|
amount: row.amount,
|
||||||
@@ -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
|
// 2. Calcular valor total
|
||||||
const totalAmountCents = installments.reduce(
|
const totalAmountCents = installments.reduce(
|
||||||
(sum, inst) => sum + Number(inst.amount) * 100,
|
(sum, inst) => sum + Number(inst.amount) * 100,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiLoader4Line } from "@remixicon/react";
|
import { RiLoader4Line } from "@remixicon/react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
createInstallmentAnticipationAction,
|
createInstallmentAnticipationAction,
|
||||||
getEligibleInstallmentsAction,
|
getEligibleInstallmentsAction,
|
||||||
} from "@/features/transactions/actions/anticipation";
|
} from "@/features/transactions/actions/anticipation";
|
||||||
|
import { installmentAnticipationsQueryKey } from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -70,6 +72,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: AnticipateInstallmentsDialogProps) {
|
}: AnticipateInstallmentsDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
||||||
@@ -86,7 +89,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, replaceForm, updateField } =
|
const { formState, replaceForm, updateField, updateFields } =
|
||||||
useFormState<AnticipationFormValues>({
|
useFormState<AnticipationFormValues>({
|
||||||
anticipationPeriod: defaultPeriod,
|
anticipationPeriod: defaultPeriod,
|
||||||
discount: "0",
|
discount: "0",
|
||||||
@@ -95,15 +98,34 @@ export function AnticipateInstallmentsDialog({
|
|||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buscar parcelas elegíveis ao abrir o dialog
|
// Resetar formulário ao abrir o dialog
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
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);
|
setIsLoadingInstallments(true);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
getEligibleInstallmentsAction(seriesId)
|
getEligibleInstallmentsAction(seriesId, formState.anticipationPeriod)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error || "Erro ao carregar parcelas");
|
toast.error(result.error || "Erro ao carregar parcelas");
|
||||||
setEligibleInstallments([]);
|
setEligibleInstallments([]);
|
||||||
@@ -116,25 +138,30 @@ export function AnticipateInstallmentsDialog({
|
|||||||
// Pré-preencher pagador e categoria da primeira parcela
|
// Pré-preencher pagador e categoria da primeira parcela
|
||||||
if (installments.length > 0) {
|
if (installments.length > 0) {
|
||||||
const first = installments[0];
|
const first = installments[0];
|
||||||
replaceForm({
|
updateFields({
|
||||||
anticipationPeriod: defaultPeriod,
|
|
||||||
discount: "0",
|
|
||||||
payerId: first.payerId ?? "",
|
payerId: first.payerId ?? "",
|
||||||
categoryId: first.categoryId ?? "",
|
categoryId: first.categoryId ?? "",
|
||||||
note: "",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
console.error("Erro ao buscar parcelas:", error);
|
console.error("Erro ao buscar parcelas:", error);
|
||||||
toast.error("Erro ao carregar parcelas elegíveis");
|
toast.error("Erro ao carregar parcelas elegíveis");
|
||||||
setEligibleInstallments([]);
|
setEligibleInstallments([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
setIsLoadingInstallments(false);
|
setIsLoadingInstallments(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
shouldUpdate = false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
|
}, [dialogOpen, formState.anticipationPeriod, seriesId, updateFields]);
|
||||||
|
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
return eligibleInstallments
|
return eligibleInstallments
|
||||||
@@ -189,6 +216,9 @@ export function AnticipateInstallmentsDialog({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||||
|
});
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error || "Erro ao criar antecipação";
|
const errorMsg = result.error || "Erro ao criar antecipação";
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
|
||||||
import {
|
import {
|
||||||
installmentAnticipationsQueryKey,
|
installmentAnticipationsQueryKey,
|
||||||
useInstallmentAnticipations,
|
useInstallmentAnticipations,
|
||||||
} from "@/features/transactions/hooks/use-installment-anticipations";
|
} from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -31,7 +36,6 @@ interface AnticipationHistoryDialogProps {
|
|||||||
lancamentoName: string;
|
lancamentoName: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnticipationHistoryDialog({
|
export function AnticipationHistoryDialog({
|
||||||
@@ -40,7 +44,6 @@ export function AnticipationHistoryDialog({
|
|||||||
lancamentoName,
|
lancamentoName,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onViewLancamento,
|
|
||||||
}: AnticipationHistoryDialogProps) {
|
}: AnticipationHistoryDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
@@ -51,34 +54,118 @@ export function AnticipationHistoryDialog({
|
|||||||
const {
|
const {
|
||||||
data: anticipations = [],
|
data: anticipations = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isFetching,
|
||||||
isError,
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
} = 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({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: installmentAnticipationsQueryKey(seriesId),
|
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 (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
|
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
||||||
<DialogDescription>{lancamentoName}</DialogDescription>
|
<DialogDescription>{lancamentoName}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
{isLoading ? (
|
{isLoading || isFetching ? (
|
||||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
|
<LoadingState />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState onRetry={() => void refetch()} />
|
||||||
|
) : anticipations.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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" />
|
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
Carregando histórico...
|
Carregando histórico...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isError ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||||
|
return (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -93,12 +180,16 @@ export function AnticipationHistoryDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
onClick={() => void refetch()}
|
onClick={onRetry}
|
||||||
>
|
>
|
||||||
Tentar novamente
|
Tentar novamente
|
||||||
</Button>
|
</Button>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : anticipations.length === 0 ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -106,32 +197,9 @@ export function AnticipationHistoryDialog({
|
|||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
As antecipações realizadas para esta compra parcelada
|
As antecipações realizadas para esta compra parcelada aparecerão aqui.
|
||||||
aparecerão aqui.
|
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : (
|
|
||||||
anticipations.map((anticipation) => (
|
|
||||||
<AnticipationCard
|
|
||||||
key={anticipation.id}
|
|
||||||
anticipation={anticipation}
|
|
||||||
onViewLancamento={onViewLancamento}
|
|
||||||
onCanceled={handleCanceled}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export function InstallmentSelectionTable({
|
|||||||
Nenhuma parcela elegível para antecipação encontrada.
|
Nenhuma parcela elegível para antecipação encontrada.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -851,16 +851,6 @@ export function TransactionsPage({
|
|||||||
onOpenChange={setAnticipationHistoryOpen}
|
onOpenChange={setAnticipationHistoryOpen}
|
||||||
seriesId={selectedForAnticipation.seriesId as string}
|
seriesId={selectedForAnticipation.seriesId as string}
|
||||||
lancamentoName={selectedForAnticipation.name}
|
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 { RiCalendarCheckLine } from "@remixicon/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useTransition } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
|
|
||||||
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
|
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 MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
@@ -23,172 +17,129 @@ import { displayPeriod } from "@/shared/utils/period";
|
|||||||
|
|
||||||
interface AnticipationCardProps {
|
interface AnticipationCardProps {
|
||||||
anticipation: InstallmentAnticipationListItem;
|
anticipation: InstallmentAnticipationListItem;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
|
||||||
onCanceled?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnticipationCard({
|
export function AnticipationCard({ anticipation }: AnticipationCardProps) {
|
||||||
anticipation,
|
|
||||||
onViewLancamento,
|
|
||||||
onCanceled,
|
|
||||||
}: AnticipationCardProps) {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const isSettled = anticipation.transaction?.isSettled === true;
|
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) => {
|
const formatDate = (date: string) => {
|
||||||
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewLancamento = () => {
|
|
||||||
onViewLancamento?.(anticipation.transactionId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="shadow-none py-2">
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
<CardHeader className="space-y-3 p-4 pb-1">
|
||||||
<div className="space-y-1">
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
<CardTitle className="text-base">
|
<div className="min-w-0 space-y-1">
|
||||||
|
<CardTitle className="text-base leading-none">
|
||||||
{anticipation.installmentCount}{" "}
|
{anticipation.installmentCount}{" "}
|
||||||
{anticipation.installmentCount === 1
|
{anticipation.installmentCount === 1
|
||||||
? "parcela antecipada"
|
? "parcela antecipada"
|
||||||
: "parcelas antecipadas"}
|
: "parcelas antecipadas"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
|
||||||
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{formatDate(anticipation.anticipationDate)}
|
<RiCalendarCheckLine className="size-3 shrink-0" />
|
||||||
</CardDescription>
|
<span>{formatDate(anticipation.anticipationDate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="secondary" className="shrink-0 rounded-full px-3">
|
||||||
{displayPeriod(anticipation.anticipationPeriod)}
|
{displayPeriod(anticipation.anticipationPeriod)}
|
||||||
</Badge>
|
</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>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="px-4 pb-4 pt-0">
|
||||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
<div>
|
<DetailItem label="Valor Original">
|
||||||
<dt className="text-muted-foreground">Valor Original</dt>
|
<MoneyValues amount={totalAmount} />
|
||||||
<dd className="mt-1 font-medium">
|
</DetailItem>
|
||||||
<MoneyValues amount={Number(anticipation.totalAmount)} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Number(anticipation.discount) > 0 && (
|
{hasDiscount ? (
|
||||||
<div>
|
<DetailItem label="Desconto" valueClassName="text-success">
|
||||||
<dt className="text-muted-foreground">Desconto</dt>
|
- <MoneyValues amount={discount} />
|
||||||
<dd className="mt-1 font-medium text-success">
|
</DetailItem>
|
||||||
- <MoneyValues amount={Number(anticipation.discount)} />
|
) : (
|
||||||
</dd>
|
<div />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<DetailItem label="Status">
|
||||||
className={
|
<Badge
|
||||||
Number(anticipation.discount) > 0
|
variant={isSettled ? "success" : "outline"}
|
||||||
? "col-span-2 border-t pt-3"
|
className="h-5 rounded-full px-2 text-xs"
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<dt className="text-muted-foreground">Status do Lançamento</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<Badge variant={isSettled ? "success" : "outline"}>
|
|
||||||
{isSettled ? "Pago" : "Pendente"}
|
{isSettled ? "Pago" : "Pendente"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</dd>
|
</DetailItem>
|
||||||
</div>
|
|
||||||
|
|
||||||
{anticipation.payer && (
|
{anticipation.payer ? (
|
||||||
<div>
|
<DetailItem label="Pessoa">{anticipation.payer.name}</DetailItem>
|
||||||
<dt className="text-muted-foreground">Pessoa</dt>
|
) : (
|
||||||
<dd className="mt-1 font-medium">{anticipation.payer.name}</dd>
|
<div />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{anticipation.category && (
|
{anticipation.category ? (
|
||||||
<div>
|
<DetailItem label="Categoria">
|
||||||
<dt className="text-muted-foreground">Categoria</dt>
|
{anticipation.category.name}
|
||||||
<dd className="mt-1 font-medium">{anticipation.category.name}</dd>
|
</DetailItem>
|
||||||
</div>
|
) : null}
|
||||||
)}
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{anticipation.note && (
|
{anticipation.note ? (
|
||||||
<div className="rounded-lg border p-3">
|
<div className="mt-3 border-t pt-3">
|
||||||
<dt className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Observação
|
Observação
|
||||||
</dt>
|
</p>
|
||||||
<dd className="mt-1 text-sm">{anticipation.note}</dd>
|
<p className="mt-1 text-sm leading-snug">{anticipation.note}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</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>
|
</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