mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(faturas/extrato): ajuste de fatura, reembolso e ajuste de saldo da conta
- botão "Ajustar fatura" na página da fatura abre dialog com input do valor real e preview da diferença; action faz upsert/delete idempotente do lançamento de ajuste - opção "Reembolso" no dropdown de ações de despesas à vista cria receita espelhada no extrato ou fatura correta, vinculada ao lançamento original - botão "Ajustar saldo" no extrato da conta compara saldo real informado e gera lançamento de ajuste por (accountId, period) via upsert/delete idempotente - constantes INVOICE_ADJUSTMENT_NAME, ACCOUNT_BALANCE_ADJUSTMENT_NAME, REFUND_NOTE_PREFIX e buildRefundNote() centralizadas em shared/lib/accounts/constants.ts - extrato agora contabiliza transferências internas em Entradas e Saídas corretamente Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RiEditLine } from "@remixicon/react";
|
||||
import { RiEditLine, RiEqualizerLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -10,11 +10,30 @@ import {
|
||||
updateInvoicePaymentStatusAction,
|
||||
updatePaymentDateAction,
|
||||
} from "@/features/invoices/actions";
|
||||
import { AccountCardSelectContent } from "@/features/transactions/components/select-items";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
@@ -27,8 +46,15 @@ import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatDateOnly } from "@/shared/utils/date";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { AdjustInvoiceDialog } from "./adjust-invoice-dialog";
|
||||
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
|
||||
|
||||
type PaymentAccountOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
type InvoiceSummaryCardProps = {
|
||||
cardId: string;
|
||||
period: string;
|
||||
@@ -42,6 +68,8 @@ type InvoiceSummaryCardProps = {
|
||||
limitAmount: number | null;
|
||||
invoiceStatus: InvoicePaymentStatus;
|
||||
paymentDate: Date | null;
|
||||
defaultPaymentAccountId: string | null;
|
||||
paymentAccountOptions: PaymentAccountOption[];
|
||||
logo?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
@@ -87,6 +115,8 @@ export function InvoiceSummaryCard({
|
||||
limitAmount,
|
||||
invoiceStatus,
|
||||
paymentDate: initialPaymentDate,
|
||||
defaultPaymentAccountId,
|
||||
paymentAccountOptions,
|
||||
logo,
|
||||
actions,
|
||||
}: InvoiceSummaryCardProps) {
|
||||
@@ -95,11 +125,21 @@ export function InvoiceSummaryCard({
|
||||
const [paymentDate, setPaymentDate] = useState<Date>(
|
||||
initialPaymentDate ?? new Date(),
|
||||
);
|
||||
const [paymentAccountId, setPaymentAccountId] = useState<string>(
|
||||
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
|
||||
);
|
||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPaymentDate(initialPaymentDate ?? new Date());
|
||||
}, [initialPaymentDate]);
|
||||
|
||||
useEffect(() => {
|
||||
setPaymentAccountId(
|
||||
defaultPaymentAccountId ?? paymentAccountOptions[0]?.value ?? "",
|
||||
);
|
||||
}, [defaultPaymentAccountId, paymentAccountOptions]);
|
||||
|
||||
const logoPath = resolveLogoSrc(logo);
|
||||
const brandAsset = resolveCardBrandAsset(cardBrand);
|
||||
const isPaid = invoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||
@@ -112,7 +152,7 @@ export function InvoiceSummaryCard({
|
||||
? INVOICE_PAYMENT_STATUS.PENDING
|
||||
: INVOICE_PAYMENT_STATUS.PAID;
|
||||
|
||||
const handleAction = () => {
|
||||
const handleAction = (accountId?: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await updateInvoicePaymentStatusAction({
|
||||
cardId,
|
||||
@@ -122,10 +162,13 @@ export function InvoiceSummaryCard({
|
||||
targetStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||
? paymentDate.toISOString().split("T")[0]
|
||||
: undefined,
|
||||
paymentAccountId:
|
||||
targetStatus === INVOICE_PAYMENT_STATUS.PAID ? accountId : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setPaymentDialogOpen(false);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
@@ -134,6 +177,15 @@ export function InvoiceSummaryCard({
|
||||
});
|
||||
};
|
||||
|
||||
const handlePaymentConfirm = () => {
|
||||
if (!paymentAccountId) {
|
||||
toast.error("Selecione uma conta para pagar a fatura.");
|
||||
return;
|
||||
}
|
||||
|
||||
handleAction(paymentAccountId);
|
||||
};
|
||||
|
||||
const handleDateChange = (newDate: Date) => {
|
||||
setPaymentDate(newDate);
|
||||
startTransition(async () => {
|
||||
@@ -190,13 +242,31 @@ export function InvoiceSummaryCard({
|
||||
{/* Linha 2 — valor da fatura (hero) */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Valor da fatura</p>
|
||||
<MoneyValues
|
||||
amount={Math.abs(totalAmount)}
|
||||
className={cn(
|
||||
"text-3xl tracking-tighter font-semibold",
|
||||
isPaid ? "text-success" : "text-foreground",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<MoneyValues
|
||||
amount={Math.abs(totalAmount)}
|
||||
className={cn(
|
||||
"text-3xl tracking-tighter font-semibold",
|
||||
isPaid ? "text-success" : "text-foreground",
|
||||
)}
|
||||
/>
|
||||
<AdjustInvoiceDialog
|
||||
cardId={cardId}
|
||||
period={period}
|
||||
currentTotal={totalAmount}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Ajustar fatura"
|
||||
>
|
||||
<RiEqualizerLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={INVOICE_STATUS_BADGE_VARIANT[invoiceStatus]}
|
||||
@@ -228,7 +298,7 @@ export function InvoiceSummaryCard({
|
||||
</MetaItem>
|
||||
|
||||
{typeof limitAmount === "number" ? (
|
||||
<MetaItem label="Limite">
|
||||
<MetaItem label="Limite total">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatCurrency(limitAmount)}
|
||||
</span>
|
||||
@@ -263,16 +333,45 @@ export function InvoiceSummaryCard({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={actionVariantByStatus[invoiceStatus]}
|
||||
disabled={isPending}
|
||||
onClick={handleAction}
|
||||
className="min-w-32"
|
||||
>
|
||||
{isPending ? "Salvando..." : actionLabelByStatus[invoiceStatus]}
|
||||
</Button>
|
||||
{isPaid ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={actionVariantByStatus[invoiceStatus]}
|
||||
disabled={isPending}
|
||||
onClick={() => handleAction()}
|
||||
className="min-w-32"
|
||||
>
|
||||
{isPending
|
||||
? "Salvando..."
|
||||
: actionLabelByStatus[invoiceStatus]}
|
||||
</Button>
|
||||
) : (
|
||||
<PayInvoiceDialog
|
||||
open={paymentDialogOpen}
|
||||
onOpenChange={setPaymentDialogOpen}
|
||||
isPending={isPending}
|
||||
paymentDate={paymentDate}
|
||||
onPaymentDateChange={setPaymentDate}
|
||||
accountId={paymentAccountId}
|
||||
onAccountChange={setPaymentAccountId}
|
||||
accountOptions={paymentAccountOptions}
|
||||
onConfirm={handlePaymentConfirm}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={actionVariantByStatus[invoiceStatus]}
|
||||
disabled={isPending}
|
||||
className="min-w-32"
|
||||
>
|
||||
{isPending
|
||||
? "Salvando..."
|
||||
: actionLabelByStatus[invoiceStatus]}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isPaid ? (
|
||||
<EditPaymentDateDialog
|
||||
trigger={
|
||||
@@ -308,3 +407,112 @@ function MetaItem({ label, children }: { label: string; children: ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PayInvoiceDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isPending: boolean;
|
||||
paymentDate: Date;
|
||||
onPaymentDateChange: (date: Date) => void;
|
||||
accountId: string;
|
||||
onAccountChange: (accountId: string) => void;
|
||||
accountOptions: PaymentAccountOption[];
|
||||
onConfirm: () => void;
|
||||
trigger: ReactNode;
|
||||
};
|
||||
|
||||
function PayInvoiceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
isPending,
|
||||
paymentDate,
|
||||
onPaymentDateChange,
|
||||
accountId,
|
||||
onAccountChange,
|
||||
accountOptions,
|
||||
onConfirm,
|
||||
trigger,
|
||||
}: PayInvoiceDialogProps) {
|
||||
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
|
||||
const selectedAccount = accountOptions.find(
|
||||
(option) => option.value === accountId,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmar pagamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha a conta de origem e a data em que a fatura foi paga.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice-payment-account">Conta de pagamento</Label>
|
||||
<Select
|
||||
value={accountId}
|
||||
onValueChange={onAccountChange}
|
||||
disabled={isPending || accountOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger id="invoice-payment-account" className="w-full">
|
||||
<SelectValue placeholder="Selecione uma conta">
|
||||
{selectedAccount ? (
|
||||
<AccountCardSelectContent
|
||||
label={selectedAccount.label}
|
||||
logo={selectedAccount.logo}
|
||||
/>
|
||||
) : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AccountCardSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice-payment-date">Data do pagamento</Label>
|
||||
<DatePicker
|
||||
id="invoice-payment-date"
|
||||
value={paymentDateValue}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
onPaymentDateChange(new Date(`${value}T00:00:00`));
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPending || accountOptions.length === 0}
|
||||
>
|
||||
{isPending ? "Confirmando..." : "Confirmar pagamento"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user