refactor: simplificar lancamento-dialog e mass-add-dialog
Substitui FaturaWarningDialog por deriveCreditCardPeriod() que calcula o período da fatura automaticamente a partir da data de compra + dia de fechamento/vencimento do cartão. lancamento-dialog: remove periodDirty state, adiciona seção colapsável "Condições e anotações", propaga closingDay/dueDay via cardInfo. mass-add-dialog: unifica contaId/cartaoId em contaCartaoId com parsing por prefixo, period picker apenas para cartão de crédito. basic-fields-section: remove PeriodPicker (período agora auto-derivado), move Estabelecimento para topo. payment-method-section: adiciona InlinePeriodPicker como link "Fatura de [mês]" com popover MonthPicker. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
faturas,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||
import { getTodayDate, parseLocalDateString } from "@/lib/utils/date";
|
||||
import { getNextPeriod } from "@/lib/utils/period";
|
||||
|
||||
// ============================================================================
|
||||
// Authorization Validation Functions
|
||||
@@ -1641,59 +1639,6 @@ export async function deleteMultipleLancamentosAction(
|
||||
}
|
||||
}
|
||||
|
||||
// Check fatura payment status and card closing day for the given period
|
||||
export async function checkFaturaStatusAction(
|
||||
cartaoId: string,
|
||||
period: string,
|
||||
): Promise<{
|
||||
shouldSuggestNext: boolean;
|
||||
isPaid: boolean;
|
||||
isAfterClosing: boolean;
|
||||
closingDay: string | null;
|
||||
cardName: string;
|
||||
nextPeriod: string;
|
||||
} | null> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
const cartao = await db.query.cartoes.findFirst({
|
||||
where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, user.id)),
|
||||
columns: { id: true, name: true, closingDay: true },
|
||||
});
|
||||
|
||||
if (!cartao) return null;
|
||||
|
||||
const fatura = await db.query.faturas.findFirst({
|
||||
where: and(
|
||||
eq(faturas.cartaoId, cartaoId),
|
||||
eq(faturas.userId, user.id),
|
||||
eq(faturas.period, period),
|
||||
),
|
||||
columns: { paymentStatus: true },
|
||||
});
|
||||
|
||||
const isPaid = fatura?.paymentStatus === "pago";
|
||||
const today = new Date();
|
||||
const currentPeriod = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
|
||||
const closingDayNum = Number.parseInt(cartao.closingDay ?? "", 10);
|
||||
const isAfterClosing =
|
||||
period === currentPeriod &&
|
||||
!Number.isNaN(closingDayNum) &&
|
||||
today.getDate() > closingDayNum;
|
||||
|
||||
return {
|
||||
shouldSuggestNext: isPaid || isAfterClosing,
|
||||
isPaid,
|
||||
isAfterClosing,
|
||||
closingDay: cartao.closingDay,
|
||||
cardName: cartao.name,
|
||||
nextPeriod: getNextPeriod(period),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique establishment names from the last 3 months
|
||||
export async function getRecentEstablishmentsAction(): Promise<string[]> {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { RiCalculatorLine } from "@remixicon/react";
|
||||
import { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog";
|
||||
import { PeriodPicker } from "@/components/period-picker";
|
||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -15,39 +14,28 @@ export function BasicFieldsSection({
|
||||
estabelecimentos,
|
||||
}: Omit<BasicFieldsSectionProps, "monthOptions">) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div className="w-1/2 space-y-1">
|
||||
<Label htmlFor="purchaseDate">Data da transação</Label>
|
||||
<DatePicker
|
||||
id="purchaseDate"
|
||||
value={formState.purchaseDate}
|
||||
onChange={(value) => onFieldChange("purchaseDate", value)}
|
||||
placeholder="Data da transação"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 space-y-1">
|
||||
<Label htmlFor="period">Período</Label>
|
||||
<PeriodPicker
|
||||
value={formState.period}
|
||||
onChange={(value) => onFieldChange("period", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Estabelecimento</Label>
|
||||
<EstabelecimentoInput
|
||||
id="name"
|
||||
value={formState.name}
|
||||
onChange={(value) => onFieldChange("name", value)}
|
||||
estabelecimentos={estabelecimentos}
|
||||
placeholder="Ex.: Restaurante do Zé"
|
||||
maxLength={20}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div className="w-1/2 space-y-1">
|
||||
<Label htmlFor="name">Estabelecimento</Label>
|
||||
<EstabelecimentoInput
|
||||
id="name"
|
||||
value={formState.name}
|
||||
onChange={(value) => onFieldChange("name", value)}
|
||||
estabelecimentos={estabelecimentos}
|
||||
placeholder="Ex.: Padaria"
|
||||
maxLength={20}
|
||||
<Label htmlFor="purchaseDate">Data</Label>
|
||||
<DatePicker
|
||||
id="purchaseDate"
|
||||
value={formState.purchaseDate}
|
||||
onChange={(value) => onFieldChange("purchaseDate", value)}
|
||||
placeholder="Data"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -74,6 +62,6 @@ export function BasicFieldsSection({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function BoletoFieldsSection({
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2 w-full",
|
||||
"space-y-1 w-full",
|
||||
showPaymentDate ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -32,13 +32,16 @@ export function ConditionSection({
|
||||
return Number.isNaN(value) || value <= 0 ? null : value;
|
||||
}, [formState.amount]);
|
||||
|
||||
const getInstallmentLabel = (count: number) => {
|
||||
if (amount) {
|
||||
const installmentValue = amount / count;
|
||||
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
|
||||
}
|
||||
return `${count}x`;
|
||||
};
|
||||
const getInstallmentLabel = useCallback(
|
||||
(count: number) => {
|
||||
if (amount) {
|
||||
const installmentValue = amount / count;
|
||||
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
|
||||
}
|
||||
return `${count}x`;
|
||||
},
|
||||
[amount],
|
||||
);
|
||||
|
||||
const _getRecurrenceLabel = (count: number) => {
|
||||
return `${count} meses`;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
checkFaturaStatusAction,
|
||||
createLancamentoAction,
|
||||
updateLancamentoAction,
|
||||
} from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,10 +36,6 @@ import {
|
||||
applyFieldDependencies,
|
||||
buildLancamentoInitialState,
|
||||
} from "@/lib/lancamentos/form-helpers";
|
||||
import {
|
||||
type FaturaWarning,
|
||||
FaturaWarningDialog,
|
||||
} from "../fatura-warning-dialog";
|
||||
import { BasicFieldsSection } from "./basic-fields-section";
|
||||
import { BoletoFieldsSection } from "./boleto-fields-section";
|
||||
import { CategorySection } from "./category-section";
|
||||
@@ -93,13 +93,8 @@ export function LancamentoDialog({
|
||||
isImporting,
|
||||
}),
|
||||
);
|
||||
const [periodDirty, setPeriodDirty] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [faturaWarning, setFaturaWarning] = useState<FaturaWarning | null>(
|
||||
null,
|
||||
);
|
||||
const lastCheckedRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
@@ -120,11 +115,6 @@ export function LancamentoDialog({
|
||||
),
|
||||
);
|
||||
setErrorMessage(null);
|
||||
setPeriodDirty(false);
|
||||
setFaturaWarning(null);
|
||||
lastCheckedRef.current = null;
|
||||
} else {
|
||||
setFaturaWarning(null);
|
||||
}
|
||||
}, [
|
||||
dialogOpen,
|
||||
@@ -140,40 +130,6 @@ export function LancamentoDialog({
|
||||
isImporting,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "create") return;
|
||||
if (!dialogOpen) return;
|
||||
if (formState.paymentMethod !== "Cartão de crédito") return;
|
||||
if (!formState.cartaoId) return;
|
||||
|
||||
const checkKey = `${formState.cartaoId}:${formState.period}`;
|
||||
if (checkKey === lastCheckedRef.current) return;
|
||||
lastCheckedRef.current = checkKey;
|
||||
|
||||
checkFaturaStatusAction(formState.cartaoId, formState.period).then(
|
||||
(result) => {
|
||||
if (result?.shouldSuggestNext) {
|
||||
setFaturaWarning({
|
||||
nextPeriod: result.nextPeriod,
|
||||
cardName: result.cardName,
|
||||
isPaid: result.isPaid,
|
||||
isAfterClosing: result.isAfterClosing,
|
||||
closingDay: result.closingDay,
|
||||
currentPeriod: formState.period,
|
||||
});
|
||||
} else {
|
||||
setFaturaWarning(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [
|
||||
mode,
|
||||
dialogOpen,
|
||||
formState.paymentMethod,
|
||||
formState.cartaoId,
|
||||
formState.period,
|
||||
]);
|
||||
|
||||
const primaryPagador = formState.pagadorId;
|
||||
|
||||
const secondaryPagadorOptions = useMemo(
|
||||
@@ -194,19 +150,27 @@ export function LancamentoDialog({
|
||||
return Number.isNaN(parsed) ? 0 : Math.abs(parsed);
|
||||
}, [formState.amount]);
|
||||
|
||||
const getCardInfo = useCallback(
|
||||
(cartaoId: string | undefined) => {
|
||||
if (!cartaoId) return null;
|
||||
const card = cartaoOptions.find((opt) => opt.value === cartaoId);
|
||||
if (!card) return null;
|
||||
return {
|
||||
closingDay: card.closingDay ?? null,
|
||||
dueDay: card.dueDay ?? null,
|
||||
};
|
||||
},
|
||||
[cartaoOptions],
|
||||
);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
<Key extends keyof FormState>(key: Key, value: FormState[Key]) => {
|
||||
if (key === "period") {
|
||||
setPeriodDirty(true);
|
||||
}
|
||||
|
||||
setFormState((prev) => {
|
||||
const dependencies = applyFieldDependencies(
|
||||
key,
|
||||
value,
|
||||
prev,
|
||||
periodDirty,
|
||||
);
|
||||
const effectiveCartaoId =
|
||||
key === "cartaoId" ? (value as string) : prev.cartaoId;
|
||||
const cardInfo = getCardInfo(effectiveCartaoId);
|
||||
|
||||
const dependencies = applyFieldDependencies(key, value, prev, cardInfo);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
@@ -215,7 +179,7 @@ export function LancamentoDialog({
|
||||
};
|
||||
});
|
||||
},
|
||||
[periodDirty],
|
||||
[getCardInfo],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
@@ -440,114 +404,115 @@ export function LancamentoDialog({
|
||||
const disableCartaoSelect = Boolean(lockCartaoSelection && mode === "create");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="sm:max-w-xl p-6 sm:px-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-2 -mx-6 max-h-[80vh] overflow-y-auto px-6 pb-1"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
<form
|
||||
className="space-y-3 -mx-6 max-h-[80vh] overflow-y-auto px-6 pb-1"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<BasicFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
estabelecimentos={estabelecimentos}
|
||||
/>
|
||||
|
||||
<CategorySection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
categoriaOptions={categoriaOptions}
|
||||
categoriaGroups={categoriaGroups}
|
||||
isUpdateMode={isUpdateMode}
|
||||
hideTransactionType={
|
||||
Boolean(isNewWithType) && !forceShowTransactionType
|
||||
}
|
||||
/>
|
||||
|
||||
{!isUpdateMode ? (
|
||||
<SplitAndSettlementSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showSettledToggle={showSettledToggle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PagadorSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
pagadorOptions={pagadorOptions}
|
||||
secondaryPagadorOptions={secondaryPagadorOptions}
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
|
||||
<PaymentMethodSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
isUpdateMode={isUpdateMode}
|
||||
disablePaymentMethod={disablePaymentMethod}
|
||||
disableCartaoSelect={disableCartaoSelect}
|
||||
/>
|
||||
|
||||
{showDueDate ? (
|
||||
<BoletoFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showPaymentDate={showPaymentDate}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Collapsible
|
||||
defaultOpen={
|
||||
formState.condition !== "À vista" || formState.note.length > 0
|
||||
}
|
||||
>
|
||||
<BasicFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
estabelecimentos={estabelecimentos}
|
||||
/>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||
<RiAddLine className="text-primary size-4 transition-transform duration-200" />
|
||||
Condições e anotações
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{!isUpdateMode ? (
|
||||
<ConditionSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showInstallments={showInstallments}
|
||||
showRecurrence={showRecurrence}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CategorySection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
categoriaOptions={categoriaOptions}
|
||||
categoriaGroups={categoriaGroups}
|
||||
isUpdateMode={isUpdateMode}
|
||||
hideTransactionType={
|
||||
Boolean(isNewWithType) && !forceShowTransactionType
|
||||
}
|
||||
/>
|
||||
|
||||
{!isUpdateMode ? (
|
||||
<SplitAndSettlementSection
|
||||
<NoteSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showSettledToggle={showSettledToggle}
|
||||
/>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<PagadorSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
pagadorOptions={pagadorOptions}
|
||||
secondaryPagadorOptions={secondaryPagadorOptions}
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<PaymentMethodSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
isUpdateMode={isUpdateMode}
|
||||
disablePaymentMethod={disablePaymentMethod}
|
||||
disableCartaoSelect={disableCartaoSelect}
|
||||
/>
|
||||
|
||||
{showDueDate ? (
|
||||
<BoletoFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showPaymentDate={showPaymentDate}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isUpdateMode ? (
|
||||
<ConditionSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showInstallments={showInstallments}
|
||||
showRecurrence={showRecurrence}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<NoteSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<FaturaWarningDialog
|
||||
warning={faturaWarning}
|
||||
onConfirm={(nextPeriod) => {
|
||||
handleFieldChange("period", nextPeriod);
|
||||
setFaturaWarning(null);
|
||||
}}
|
||||
onCancel={() => setFaturaWarning(null)}
|
||||
/>
|
||||
</>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MonthPicker } from "@/components/ui/monthpicker";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -9,6 +16,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
||||
import { displayPeriod } from "@/lib/utils/period";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import {
|
||||
ContaCartaoSelectContent,
|
||||
@@ -16,6 +24,52 @@ import {
|
||||
} from "../../select-items";
|
||||
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
|
||||
|
||||
function periodToDate(period: string): Date {
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
return new Date(year, month - 1, 1);
|
||||
}
|
||||
|
||||
function dateToPeriod(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}-${month}`;
|
||||
}
|
||||
|
||||
function InlinePeriodPicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: string;
|
||||
onPeriodChange: (value: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="ml-1">
|
||||
<span className="text-xs text-muted-foreground">Fatura de </span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline-offset-2 hover:underline cursor-pointer lowercase"
|
||||
>
|
||||
{displayPeriod(period)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<MonthPicker
|
||||
selectedMonth={periodToDate(period)}
|
||||
onMonthSelect={(date) => {
|
||||
onPeriodChange(dateToPeriod(date));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentMethodSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
@@ -46,7 +100,7 @@ export function PaymentMethodSection({
|
||||
return (
|
||||
<>
|
||||
{!isUpdateMode ? (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row mt-3">
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
@@ -131,6 +185,12 @@ export function PaymentMethodSection({
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.cartaoId ? (
|
||||
<InlinePeriodPicker
|
||||
period={formState.period}
|
||||
onPeriodChange={(value) => onFieldChange("period", value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -239,6 +299,12 @@ export function PaymentMethodSection({
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.cartaoId ? (
|
||||
<InlinePeriodPicker
|
||||
period={formState.period}
|
||||
onPeriodChange={(value) => onFieldChange("period", value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export function SplitAndSettlementSection({
|
||||
showSettledToggle,
|
||||
}: SplitAndSettlementSectionProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 py-2 md:flex-row">
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { checkFaturaStatusAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { PeriodPicker } from "@/components/period-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||
@@ -40,10 +39,6 @@ import {
|
||||
TransactionTypeSelectContent,
|
||||
} from "../select-items";
|
||||
import { EstabelecimentoInput } from "../shared/estabelecimento-input";
|
||||
import {
|
||||
type FaturaWarning,
|
||||
FaturaWarningDialog,
|
||||
} from "./fatura-warning-dialog";
|
||||
|
||||
interface MassAddDialogProps {
|
||||
open: boolean;
|
||||
@@ -124,39 +119,6 @@ export function MassAddDialog({
|
||||
? contaCartaoId.replace("cartao:", "")
|
||||
: undefined;
|
||||
|
||||
const [faturaWarning, setFaturaWarning] = useState<FaturaWarning | null>(
|
||||
null,
|
||||
);
|
||||
const lastCheckedRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setFaturaWarning(null);
|
||||
lastCheckedRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!isCartaoSelected || !cartaoId) return;
|
||||
|
||||
const checkKey = `${cartaoId}:${period}`;
|
||||
if (checkKey === lastCheckedRef.current) return;
|
||||
lastCheckedRef.current = checkKey;
|
||||
|
||||
checkFaturaStatusAction(cartaoId, period).then((result) => {
|
||||
if (result?.shouldSuggestNext) {
|
||||
setFaturaWarning({
|
||||
nextPeriod: result.nextPeriod,
|
||||
cardName: result.cardName,
|
||||
isPaid: result.isPaid,
|
||||
isAfterClosing: result.isAfterClosing,
|
||||
closingDay: result.closingDay,
|
||||
currentPeriod: period,
|
||||
});
|
||||
} else {
|
||||
setFaturaWarning(null);
|
||||
}
|
||||
});
|
||||
}, [open, isCartaoSelected, cartaoId, period]);
|
||||
|
||||
// Transaction rows
|
||||
const [transactions, setTransactions] = useState<TransactionRow[]>([
|
||||
{
|
||||
@@ -275,403 +237,387 @@ export function MassAddDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// Show period picker only for credit card
|
||||
const showPeriodPicker = isCartaoSelected;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure os valores padrão e adicione várias transações de uma
|
||||
vez. Todos os lançamentos adicionados aqui são{" "}
|
||||
<span className="font-bold">sempre à vista</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure os valores padrão e adicione várias transações de uma vez.
|
||||
Todos os lançamentos adicionados aqui são{" "}
|
||||
<span className="font-bold">sempre à vista</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Fixed Fields Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Valores Padrão</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Transaction Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
||||
<Select
|
||||
value={transactionType}
|
||||
onValueChange={setTransactionType}
|
||||
>
|
||||
<SelectTrigger id="transaction-type" className="w-full">
|
||||
<SelectValue>
|
||||
{transactionType && (
|
||||
<TransactionTypeSelectContent
|
||||
label={transactionType}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Despesa">
|
||||
<TransactionTypeSelectContent label="Despesa" />
|
||||
<div className="space-y-6">
|
||||
{/* Fixed Fields Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Valores Padrão</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Transaction Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
||||
<Select
|
||||
value={transactionType}
|
||||
onValueChange={setTransactionType}
|
||||
>
|
||||
<SelectTrigger id="transaction-type" className="w-full">
|
||||
<SelectValue>
|
||||
{transactionType && (
|
||||
<TransactionTypeSelectContent label={transactionType} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Despesa">
|
||||
<TransactionTypeSelectContent label="Despesa" />
|
||||
</SelectItem>
|
||||
<SelectItem value="Receita">
|
||||
<TransactionTypeSelectContent label="Receita" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-method">Forma de Pagamento</Label>
|
||||
<Select
|
||||
value={paymentMethod}
|
||||
onValueChange={(value) => {
|
||||
setPaymentMethod(value);
|
||||
// Reset conta/cartao when changing payment method
|
||||
if (value === "Cartão de crédito") {
|
||||
setContaCartaoId(undefined);
|
||||
} else {
|
||||
setContaCartaoId(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="payment-method" className="w-full">
|
||||
<SelectValue>
|
||||
{paymentMethod && (
|
||||
<PaymentMethodSelectContent label={paymentMethod} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANCAMENTO_PAYMENT_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
<PaymentMethodSelectContent label={method} />
|
||||
</SelectItem>
|
||||
<SelectItem value="Receita">
|
||||
<TransactionTypeSelectContent label="Receita" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{/* Period - only for credit card */}
|
||||
{showPeriodPicker ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-method">Forma de Pagamento</Label>
|
||||
<Select
|
||||
value={paymentMethod}
|
||||
onValueChange={(value) => {
|
||||
setPaymentMethod(value);
|
||||
// Reset conta/cartao when changing payment method
|
||||
if (value === "Cartão de crédito") {
|
||||
setContaId(undefined);
|
||||
} else {
|
||||
setCartaoId(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="payment-method" className="w-full">
|
||||
<SelectValue>
|
||||
{paymentMethod && (
|
||||
<PaymentMethodSelectContent label={paymentMethod} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANCAMENTO_PAYMENT_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
<PaymentMethodSelectContent label={method} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Period */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="period">Período</Label>
|
||||
<Label htmlFor="period">Fatura</Label>
|
||||
<PeriodPicker
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
className="w-full truncate"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Conta/Cartao */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conta-cartao">
|
||||
{isLockedToCartao ? "Cartão" : "Conta/Cartão"}
|
||||
</Label>
|
||||
<Select
|
||||
value={contaCartaoId}
|
||||
onValueChange={setContaCartaoId}
|
||||
disabled={isLockedToCartao}
|
||||
>
|
||||
<SelectTrigger id="conta-cartao" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{contaCartaoId &&
|
||||
(() => {
|
||||
if (isCartaoSelected) {
|
||||
const selectedOption = cartaoOptions.find(
|
||||
(opt) => opt.value === cartaoId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
} else {
|
||||
const selectedOption = contaOptions.find(
|
||||
(opt) => opt.value === contaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cartaoOptions.length > 0 && (
|
||||
<SelectGroup>
|
||||
{!isLockedToCartao && (
|
||||
<SelectLabel>Cartões</SelectLabel>
|
||||
)}
|
||||
{cartaoOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
!isLockedToCartao ||
|
||||
option.value === defaultCartaoId,
|
||||
)
|
||||
.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={`cartao:${option.value}`}
|
||||
>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{!isLockedToCartao && contaOptions.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Contas</SelectLabel>
|
||||
{contaOptions.map((option) => (
|
||||
{/* Conta/Cartao */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conta-cartao">
|
||||
{isLockedToCartao ? "Cartão" : "Conta/Cartão"}
|
||||
</Label>
|
||||
<Select
|
||||
value={contaCartaoId}
|
||||
onValueChange={setContaCartaoId}
|
||||
disabled={isLockedToCartao}
|
||||
>
|
||||
<SelectTrigger id="conta-cartao" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{contaCartaoId &&
|
||||
(() => {
|
||||
if (isCartaoSelected) {
|
||||
const selectedOption = cartaoOptions.find(
|
||||
(opt) => opt.value === cartaoId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
} else {
|
||||
const selectedOption = contaOptions.find(
|
||||
(opt) => opt.value === contaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cartaoOptions.length > 0 && (
|
||||
<SelectGroup>
|
||||
{!isLockedToCartao && (
|
||||
<SelectLabel>Cartões</SelectLabel>
|
||||
)}
|
||||
{cartaoOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
!isLockedToCartao ||
|
||||
option.value === defaultCartaoId,
|
||||
)
|
||||
.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={`conta:${option.value}`}
|
||||
value={`cartao:${option.value}`}
|
||||
>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Transactions Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Lançamentos</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{transactions.map((transaction, index) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="grid gap-2 border-b pb-3 border-dashed last:border-0"
|
||||
>
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`date-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Data {index + 1}
|
||||
</Label>
|
||||
<DatePicker
|
||||
id={`date-${transaction.id}`}
|
||||
value={transaction.purchaseDate}
|
||||
onChange={(value) =>
|
||||
updateTransaction(
|
||||
transaction.id,
|
||||
"purchaseDate",
|
||||
value,
|
||||
)
|
||||
}
|
||||
placeholder="Data"
|
||||
className="w-32 truncate"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`name-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Estabelecimento {index + 1}
|
||||
</Label>
|
||||
<EstabelecimentoInput
|
||||
id={`name-${transaction.id}`}
|
||||
placeholder="Local"
|
||||
value={transaction.name}
|
||||
onChange={(value) =>
|
||||
updateTransaction(transaction.id, "name", value)
|
||||
}
|
||||
estabelecimentos={estabelecimentos}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`amount-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Valor {index + 1}
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id={`amount-${transaction.id}`}
|
||||
placeholder="R$ 0,00"
|
||||
value={transaction.amount}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(transaction.id, "amount", value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`pagador-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Pagador {index + 1}
|
||||
</Label>
|
||||
<Select
|
||||
value={transaction.pagadorId}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(
|
||||
transaction.id,
|
||||
"pagadorId",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`pagador-${transaction.id}`}
|
||||
className="w-32 truncate"
|
||||
</SelectGroup>
|
||||
)}
|
||||
{!isLockedToCartao && contaOptions.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Contas</SelectLabel>
|
||||
{contaOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={`conta:${option.value}`}
|
||||
>
|
||||
<SelectValue placeholder="Pagador">
|
||||
{transaction.pagadorId &&
|
||||
(() => {
|
||||
const selectedOption = pagadorOptions.find(
|
||||
(opt) =>
|
||||
opt.value === transaction.pagadorId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PagadorSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pagadorOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<PagadorSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`categoria-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Categoria {index + 1}
|
||||
</Label>
|
||||
<Select
|
||||
value={transaction.categoriaId}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(
|
||||
transaction.id,
|
||||
"categoriaId",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`categoria-${transaction.id}`}
|
||||
className="w-32 truncate"
|
||||
>
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedCategorias.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<CategoriaSelectContent
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={addTransaction}
|
||||
>
|
||||
<RiAddLine className="size-3.5" />
|
||||
<span className="sr-only">Adicionar transação</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => removeTransaction(transaction.id)}
|
||||
disabled={transactions.length === 1}
|
||||
>
|
||||
<RiDeleteBinLine className="size-3.5" />
|
||||
<span className="sr-only">Remover transação</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Spinner className="size-4" />}
|
||||
Criar {transactions.length}{" "}
|
||||
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Separator />
|
||||
|
||||
<FaturaWarningDialog
|
||||
warning={faturaWarning}
|
||||
onConfirm={(nextPeriod) => {
|
||||
setPeriod(nextPeriod);
|
||||
setFaturaWarning(null);
|
||||
}}
|
||||
onCancel={() => setFaturaWarning(null)}
|
||||
/>
|
||||
</>
|
||||
{/* Transactions Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Lançamentos</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{transactions.map((transaction, index) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="grid gap-2 border-b pb-3 border-dashed last:border-0"
|
||||
>
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`date-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Data {index + 1}
|
||||
</Label>
|
||||
<DatePicker
|
||||
id={`date-${transaction.id}`}
|
||||
value={transaction.purchaseDate}
|
||||
onChange={(value) =>
|
||||
updateTransaction(
|
||||
transaction.id,
|
||||
"purchaseDate",
|
||||
value,
|
||||
)
|
||||
}
|
||||
placeholder="Data"
|
||||
className="w-32 truncate"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`name-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Estabelecimento {index + 1}
|
||||
</Label>
|
||||
<EstabelecimentoInput
|
||||
id={`name-${transaction.id}`}
|
||||
placeholder="Local"
|
||||
value={transaction.name}
|
||||
onChange={(value) =>
|
||||
updateTransaction(transaction.id, "name", value)
|
||||
}
|
||||
estabelecimentos={estabelecimentos}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`amount-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Valor {index + 1}
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id={`amount-${transaction.id}`}
|
||||
placeholder="R$ 0,00"
|
||||
value={transaction.amount}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(transaction.id, "amount", value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`pagador-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Pagador {index + 1}
|
||||
</Label>
|
||||
<Select
|
||||
value={transaction.pagadorId}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(transaction.id, "pagadorId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`pagador-${transaction.id}`}
|
||||
className="w-32 truncate"
|
||||
>
|
||||
<SelectValue placeholder="Pagador">
|
||||
{transaction.pagadorId &&
|
||||
(() => {
|
||||
const selectedOption = pagadorOptions.find(
|
||||
(opt) => opt.value === transaction.pagadorId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PagadorSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pagadorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PagadorSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`categoria-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Categoria {index + 1}
|
||||
</Label>
|
||||
<Select
|
||||
value={transaction.categoriaId}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(
|
||||
transaction.id,
|
||||
"categoriaId",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`categoria-${transaction.id}`}
|
||||
className="w-32 truncate"
|
||||
>
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedCategorias.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<CategoriaSelectContent
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={addTransaction}
|
||||
>
|
||||
<RiAddLine className="size-3.5" />
|
||||
<span className="sr-only">Adicionar transação</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => removeTransaction(transaction.id)}
|
||||
disabled={transactions.length === 1}
|
||||
>
|
||||
<RiDeleteBinLine className="size-3.5" />
|
||||
<span className="sr-only">Remover transação</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Spinner className="size-4" />}
|
||||
Criar {transactions.length}{" "}
|
||||
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ export type SelectOption = {
|
||||
logo?: string | null;
|
||||
icon?: string | null;
|
||||
accountType?: string | null;
|
||||
closingDay?: string | null;
|
||||
dueDay?: string | null;
|
||||
};
|
||||
|
||||
export type LancamentoFilterOption = {
|
||||
|
||||
@@ -1,12 +1,64 @@
|
||||
import type { LancamentoItem } from "@/components/lancamentos/types";
|
||||
import { getTodayDateString } from "@/lib/utils/date";
|
||||
import { derivePeriodFromDate } from "@/lib/utils/period";
|
||||
import { derivePeriodFromDate, getNextPeriod } from "@/lib/utils/period";
|
||||
import {
|
||||
LANCAMENTO_CONDITIONS,
|
||||
LANCAMENTO_PAYMENT_METHODS,
|
||||
LANCAMENTO_TRANSACTION_TYPES,
|
||||
} from "./constants";
|
||||
|
||||
/**
|
||||
* Derives the fatura period for a credit card purchase based on closing day
|
||||
* and due day. The period represents the month the fatura is due (vencimento).
|
||||
*
|
||||
* Steps:
|
||||
* 1. If purchase day > closing day → the purchase missed this month's closing,
|
||||
* so it enters the NEXT month's billing cycle (+1 month from purchase).
|
||||
* 2. Then, if dueDay < closingDay, the due date falls in the month AFTER the
|
||||
* closing month (e.g., closes 22nd, due 1st → closes Mar/22, due Apr/1),
|
||||
* so we add another +1 month.
|
||||
*
|
||||
* @example
|
||||
* // Card closes day 22, due day 1 (dueDay < closingDay → +1 extra)
|
||||
* deriveCreditCardPeriod("2026-02-25", "22", "1") // "2026-04" (missed Feb closing → Mar cycle → due Apr)
|
||||
* deriveCreditCardPeriod("2026-02-15", "22", "1") // "2026-03" (in Feb cycle → due Mar)
|
||||
*
|
||||
* // Card closes day 5, due day 15 (dueDay >= closingDay → no extra)
|
||||
* deriveCreditCardPeriod("2026-02-10", "5", "15") // "2026-03" (missed Feb closing → Mar cycle → due Mar)
|
||||
* deriveCreditCardPeriod("2026-02-03", "5", "15") // "2026-02" (in Feb cycle → due Feb)
|
||||
*/
|
||||
export function deriveCreditCardPeriod(
|
||||
purchaseDate: string,
|
||||
closingDay: string | null | undefined,
|
||||
dueDay?: string | null | undefined,
|
||||
): string {
|
||||
const basePeriod = derivePeriodFromDate(purchaseDate);
|
||||
if (!closingDay) return basePeriod;
|
||||
|
||||
const closingDayNum = Number.parseInt(closingDay, 10);
|
||||
if (Number.isNaN(closingDayNum)) return basePeriod;
|
||||
|
||||
const dayPart = purchaseDate.split("-")[2];
|
||||
const purchaseDayNum = Number.parseInt(dayPart ?? "1", 10);
|
||||
|
||||
// Start with the purchase month as the billing cycle
|
||||
let period = basePeriod;
|
||||
|
||||
// If purchase is after closing day, it enters the next billing cycle
|
||||
if (purchaseDayNum > closingDayNum) {
|
||||
period = getNextPeriod(period);
|
||||
}
|
||||
|
||||
// If due day < closing day, the due date falls in the month after closing
|
||||
// (e.g., closes 22nd, due 1st → closing in March means due in April)
|
||||
const dueDayNum = Number.parseInt(dueDay ?? "", 10);
|
||||
if (!Number.isNaN(dueDayNum) && dueDayNum < closingDayNum) {
|
||||
period = getNextPeriod(period);
|
||||
}
|
||||
|
||||
return period;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split type for dividing transactions between payers
|
||||
*/
|
||||
@@ -198,16 +250,44 @@ export function applyFieldDependencies(
|
||||
key: keyof LancamentoFormState,
|
||||
value: LancamentoFormState[keyof LancamentoFormState],
|
||||
currentState: LancamentoFormState,
|
||||
_periodDirty: boolean,
|
||||
cardInfo?: { closingDay: string | null; dueDay: string | null } | null,
|
||||
): Partial<LancamentoFormState> {
|
||||
const updates: Partial<LancamentoFormState> = {};
|
||||
|
||||
// Removed automatic period update when purchase date changes
|
||||
// if (key === "purchaseDate" && typeof value === "string") {
|
||||
// if (!periodDirty) {
|
||||
// updates.period = derivePeriodFromDate(value);
|
||||
// }
|
||||
// }
|
||||
// Auto-derive period from purchaseDate
|
||||
if (key === "purchaseDate" && typeof value === "string" && value) {
|
||||
const method = currentState.paymentMethod;
|
||||
if (method === "Cartão de crédito") {
|
||||
updates.period = deriveCreditCardPeriod(
|
||||
value,
|
||||
cardInfo?.closingDay,
|
||||
cardInfo?.dueDay,
|
||||
);
|
||||
} else if (method !== "Boleto") {
|
||||
updates.period = derivePeriodFromDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-derive period from dueDate when payment method is boleto
|
||||
if (key === "dueDate" && typeof value === "string" && value) {
|
||||
if (currentState.paymentMethod === "Boleto") {
|
||||
updates.period = derivePeriodFromDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-derive period when cartaoId changes (credit card selected)
|
||||
if (
|
||||
key === "cartaoId" &&
|
||||
currentState.paymentMethod === "Cartão de crédito"
|
||||
) {
|
||||
if (typeof value === "string" && value && currentState.purchaseDate) {
|
||||
updates.period = deriveCreditCardPeriod(
|
||||
currentState.purchaseDate,
|
||||
cardInfo?.closingDay,
|
||||
cardInfo?.dueDay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// When condition changes, clear irrelevant fields
|
||||
if (key === "condition" && typeof value === "string") {
|
||||
@@ -229,6 +309,27 @@ export function applyFieldDependencies(
|
||||
updates.isSettled = currentState.isSettled ?? true;
|
||||
}
|
||||
|
||||
// Re-derive period based on new payment method
|
||||
if (value === "Cartão de crédito") {
|
||||
if (
|
||||
currentState.purchaseDate &&
|
||||
currentState.cartaoId &&
|
||||
cardInfo?.closingDay
|
||||
) {
|
||||
updates.period = deriveCreditCardPeriod(
|
||||
currentState.purchaseDate,
|
||||
cardInfo.closingDay,
|
||||
cardInfo.dueDay,
|
||||
);
|
||||
} else if (currentState.purchaseDate) {
|
||||
updates.period = derivePeriodFromDate(currentState.purchaseDate);
|
||||
}
|
||||
} else if (value === "Boleto" && currentState.dueDate) {
|
||||
updates.period = derivePeriodFromDate(currentState.dueDate);
|
||||
} else if (currentState.purchaseDate) {
|
||||
updates.period = derivePeriodFromDate(currentState.purchaseDate);
|
||||
}
|
||||
|
||||
// Clear boleto-specific fields if not boleto
|
||||
if (value !== "Boleto") {
|
||||
updates.dueDate = "";
|
||||
|
||||
Reference in New Issue
Block a user