feat: adição de novos ícones SVG e configuração do ambiente

- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter
- Implementados ícones para modos claro e escuro do ChatGPT
- Criado script de inicialização para PostgreSQL com extensão pgcrypto
- Adicionado script de configuração de ambiente que faz backup do .env
- Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
Felipe Coutinho
2025-11-15 15:49:36 -03:00
commit ea0b8618e0
441 changed files with 53569 additions and 0 deletions

View File

@@ -0,0 +1,489 @@
"use client";
import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
} from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { CategoryIcon } from "@/components/categorias/category-icon";
import { InstallmentSelectionTable } from "./installment-selection-table";
import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
} from "@/components/ui/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { RiLoader4Line } from "@remixicon/react";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import MoneyValues from "@/components/money-values";
interface AnticipateInstallmentsDialogProps {
trigger?: React.ReactNode;
seriesId: string;
lancamentoName: string;
categorias: Array<{ id: string; name: string; icon: string | null }>;
pagadores: Array<{ id: string; name: string }>;
defaultPeriod: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
type AnticipationFormValues = {
anticipationPeriod: string;
discount: number;
pagadorId: string;
categoriaId: string;
note: string;
};
type SelectOption = {
value: string;
label: string;
};
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
month: "long",
year: "numeric",
});
const formatPeriodLabel = (period: string) => {
const [year, month] = period.split("-").map(Number);
if (!year || !month) {
return period;
}
const date = new Date(year, month - 1, 1);
if (Number.isNaN(date.getTime())) {
return period;
}
const label = monthFormatter.format(date);
return label.charAt(0).toUpperCase() + label.slice(1);
};
const buildPeriodOptions = (currentValue?: string): SelectOption[] => {
const now = new Date();
const options: SelectOption[] = [];
// Adiciona opções de 3 meses no passado até 6 meses no futuro
for (let offset = -3; offset <= 6; offset += 1) {
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
2,
"0"
)}`;
options.push({ value, label: formatPeriodLabel(value) });
}
// Adiciona o valor atual se não estiver na lista
if (
currentValue &&
!options.some((option) => option.value === currentValue)
) {
options.push({
value: currentValue,
label: formatPeriodLabel(currentValue),
});
}
return options.sort((a, b) => a.value.localeCompare(b.value));
};
export function AnticipateInstallmentsDialog({
trigger,
seriesId,
lancamentoName,
categorias,
pagadores,
defaultPeriod,
open,
onOpenChange,
}: AnticipateInstallmentsDialogProps) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
const [eligibleInstallments, setEligibleInstallments] = useState<
EligibleInstallment[]
>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: 0,
pagadorId: "",
categoriaId: "",
note: "",
});
const periodOptions = useMemo(
() => buildPeriodOptions(formState.anticipationPeriod),
[formState.anticipationPeriod]
);
// Buscar parcelas elegíveis ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
setIsLoadingInstallments(true);
setSelectedIds([]);
setErrorMessage(null);
getEligibleInstallmentsAction(seriesId)
.then((result) => {
if (result.success && result.data) {
setEligibleInstallments(result.data);
// Pré-preencher pagador e categoria da primeira parcela
if (result.data.length > 0) {
const first = result.data[0];
setFormState({
anticipationPeriod: defaultPeriod,
discount: 0,
pagadorId: first.pagadorId ?? "",
categoriaId: first.categoriaId ?? "",
note: "",
});
}
} else {
toast.error(result.error || "Erro ao carregar parcelas");
setEligibleInstallments([]);
}
})
.catch((error) => {
console.error("Erro ao buscar parcelas:", error);
toast.error("Erro ao carregar parcelas elegíveis");
setEligibleInstallments([]);
})
.finally(() => {
setIsLoadingInstallments(false);
});
}
}, [dialogOpen, seriesId, defaultPeriod, setFormState]);
const totalAmount = useMemo(() => {
return eligibleInstallments
.filter((inst) => selectedIds.includes(inst.id))
.reduce((sum, inst) => sum + Number(inst.amount), 0);
}, [eligibleInstallments, selectedIds]);
const finalAmount = useMemo(() => {
// Se for despesa (negativo), soma o desconto para reduzir
// Se for receita (positivo), subtrai o desconto
const discount = Number(formState.discount) || 0;
return totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
}, [totalAmount, formState.discount]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (selectedIds.length === 0) {
const message = "Selecione pelo menos uma parcela para antecipar.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.anticipationPeriod.length === 0) {
const message = "Informe o período da antecipação.";
setErrorMessage(message);
toast.error(message);
return;
}
const discount = Number(formState.discount) || 0;
if (discount > Math.abs(totalAmount)) {
const message =
"O desconto não pode ser maior que o valor total das parcelas.";
setErrorMessage(message);
toast.error(message);
return;
}
startTransition(async () => {
const result = await createInstallmentAnticipationAction({
seriesId,
installmentIds: selectedIds,
anticipationPeriod: formState.anticipationPeriod,
discount: Number(formState.discount) || 0,
pagadorId: formState.pagadorId || undefined,
categoriaId: formState.categoriaId || undefined,
note: formState.note || undefined,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
} else {
const errorMsg = result.error || "Erro ao criar antecipação";
setErrorMessage(errorMsg);
toast.error(errorMsg);
}
});
},
[selectedIds, formState, seriesId, setDialogOpen]
);
const handleCancel = useCallback(() => {
setDialogOpen(false);
}, [setDialogOpen]);
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
<DialogTitle>Antecipar Parcelas</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Seção 1: Seleção de Parcelas */}
<FieldGroup className="gap-1">
<FieldLegend>Parcelas Disponíveis</FieldLegend>
{isLoadingInstallments ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando parcelas...
</span>
</div>
) : (
<div className="max-h-[280px] overflow-y-auto rounded-lg border">
<InstallmentSelectionTable
installments={eligibleInstallments}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
</div>
)}
</FieldGroup>
{/* Seção 2: Configuração da Antecipação */}
<FieldGroup className="gap-1">
<FieldLegend>Configuração</FieldLegend>
<div className="grid gap-2 sm:grid-cols-2">
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel>
<FieldContent>
<Select
value={formState.anticipationPeriod}
onValueChange={(value) =>
updateField("anticipationPeriod", value)
}
disabled={isPending}
>
<SelectTrigger id="anticipation-period" className="w-full">
<SelectValue placeholder="Selecione o período" />
</SelectTrigger>
<SelectContent>
{periodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-discount">
Desconto
</FieldLabel>
<FieldContent>
<CurrencyInput
id="anticipation-discount"
value={formState.discount}
onValueChange={(value) =>
updateField("discount", value ?? 0)
}
placeholder="R$ 0,00"
disabled={isPending}
/>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-pagador">Pagador</FieldLabel>
<FieldContent>
<Select
value={formState.pagadorId}
onValueChange={(value) => updateField("pagadorId", value)}
disabled={isPending}
>
<SelectTrigger id="anticipation-pagador" className="w-full">
<SelectValue placeholder="Padrão" />
</SelectTrigger>
<SelectContent>
{pagadores.map((pagador) => (
<SelectItem key={pagador.id} value={pagador.id}>
{pagador.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-categoria">
Categoria
</FieldLabel>
<FieldContent>
<Select
value={formState.categoriaId}
onValueChange={(value) => updateField("categoriaId", value)}
disabled={isPending}
>
<SelectTrigger
id="anticipation-categoria"
className="w-full"
>
<SelectValue placeholder="Padrão" />
</SelectTrigger>
<SelectContent>
{categorias.map((categoria) => (
<SelectItem key={categoria.id} value={categoria.id}>
<div className="flex items-center gap-2">
<CategoryIcon
name={categoria.icon ?? undefined}
className="size-4"
/>
<span>{categoria.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor="anticipation-note">Observação</FieldLabel>
<FieldContent>
<Textarea
id="anticipation-note"
value={formState.note}
onChange={(e) => updateField("note", e.target.value)}
placeholder="Observação (opcional)"
rows={2}
disabled={isPending}
/>
</FieldContent>
</Field>
</div>
</FieldGroup>
{/* Seção 3: Resumo */}
{selectedIds.length > 0 && (
<div className="rounded-lg border bg-muted/20 p-3">
<h4 className="text-sm font-semibold mb-2">Resumo</h4>
<dl className="space-y-1.5 text-sm">
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">
{selectedIds.length} parcela
{selectedIds.length > 1 ? "s" : ""}
</dt>
<dd className="font-medium tabular-nums">
<MoneyValues amount={totalAmount} className="text-sm" />
</dd>
</div>
{Number(formState.discount) > 0 && (
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">Desconto</dt>
<dd className="font-medium tabular-nums text-green-600">
-{" "}
<MoneyValues
amount={Number(formState.discount)}
className="text-sm"
/>
</dd>
</div>
)}
<div className="flex items-center justify-between border-t pt-1.5">
<dt className="font-medium">Total</dt>
<dd className="text-base font-semibold tabular-nums text-primary">
<MoneyValues amount={finalAmount} className="text-sm" />
</dd>
</div>
</dl>
</div>
)}
{/* Mensagem de erro */}
{errorMessage && (
<div
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
role="alert"
>
{errorMessage}
</div>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isPending}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isPending || selectedIds.length === 0}
>
{isPending ? (
<>
<RiLoader4Line className="mr-2 size-4 animate-spin" />
Antecipando...
</>
) : (
"Confirmar Antecipação"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { getInstallmentAnticipationsAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { AnticipationCard } from "../../shared/anticipation-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { useControlledState } from "@/hooks/use-controlled-state";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
interface AnticipationHistoryDialogProps {
trigger?: React.ReactNode;
seriesId: string;
lancamentoName: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onViewLancamento?: (lancamentoId: string) => void;
}
export function AnticipationHistoryDialog({
trigger,
seriesId,
lancamentoName,
open,
onOpenChange,
onViewLancamento,
}: AnticipationHistoryDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [anticipations, setAnticipations] = useState<
InstallmentAnticipationWithRelations[]
>([]);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
// Buscar antecipações ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
loadAnticipations();
}
}, [dialogOpen, seriesId]);
const loadAnticipations = async () => {
setIsLoading(true);
try {
const result = await getInstallmentAnticipationsAction(seriesId);
if (result.success && result.data) {
setAnticipations(result.data);
} else {
toast.error(result.error || "Erro ao carregar histórico de antecipações");
setAnticipations([]);
}
} catch (error) {
console.error("Erro ao buscar antecipações:", error);
toast.error("Erro ao carregar histórico de antecipações");
setAnticipations([]);
} finally {
setIsLoading(false);
}
};
const handleCanceled = () => {
// Recarregar lista após cancelamento
loadAnticipations();
};
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
<DialogTitle>Histórico de Antecipações</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
{isLoading ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando histórico...
</span>
</div>
) : anticipations.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
<EmptyDescription>
As antecipações realizadas para esta compra parcelada aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
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>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { formatCurrentInstallment } from "@/lib/installments/utils";
import { cn } from "@/lib/utils/ui";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values";
interface InstallmentSelectionTableProps {
installments: EligibleInstallment[];
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
}
export function InstallmentSelectionTable({
installments,
selectedIds,
onSelectionChange,
}: InstallmentSelectionTableProps) {
const toggleSelection = (id: string) => {
const newSelection = selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id];
onSelectionChange(newSelection);
};
const toggleAll = () => {
if (selectedIds.length === installments.length && installments.length > 0) {
onSelectionChange([]);
} else {
onSelectionChange(installments.map((inst) => inst.id));
}
};
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
const date = new Date(Number(year), Number(month) - 1);
return format(date, "MMM/yyyy", { locale: ptBR });
};
const formatDate = (date: Date | null) => {
if (!date) return "—";
return format(date, "dd/MM/yyyy", { locale: ptBR });
};
if (installments.length === 0) {
return (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma parcela elegível para antecipação encontrada.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Todas as parcelas desta compra foram pagas ou antecipadas.
</p>
</div>
);
}
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
selectedIds.length === installments.length &&
installments.length > 0
}
onCheckedChange={toggleAll}
aria-label="Selecionar todas as parcelas"
/>
</TableHead>
<TableHead>Parcela</TableHead>
<TableHead>Período</TableHead>
<TableHead>Vencimento</TableHead>
<TableHead className="text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{installments.map((inst) => {
const isSelected = selectedIds.includes(inst.id);
return (
<TableRow
key={inst.id}
className={cn(
"cursor-pointer transition-colors",
isSelected && "bg-muted/50"
)}
onClick={() => toggleSelection(inst.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(inst.id)}
aria-label={`Selecionar parcela ${inst.currentInstallment}`}
/>
</TableCell>
<TableCell>
<Badge variant="outline">
{formatCurrentInstallment(
inst.currentInstallment ?? 0,
inst.installmentCount ?? 0
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{formatPeriod(inst.period)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(inst.dueDate)}
</TableCell>
<TableCell className="text-right font-semibold tabular-nums">
<MoneyValues amount={Number(inst.amount)} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{selectedIds.length > 0 && (
<div className="border-t bg-muted/20 px-4 py-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{selectedIds.length}{" "}
{selectedIds.length === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<span className="font-semibold">
Total:{" "}
<MoneyValues
amount={installments
.filter((inst) => selectedIds.includes(inst.id))
.reduce((sum, inst) => sum + Number(inst.amount), 0)}
/>
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useState } from "react";
export type BulkActionScope = "current" | "future" | "all";
type BulkActionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
actionType: "edit" | "delete";
seriesType: "installment" | "recurring";
currentNumber?: number;
totalCount?: number;
onConfirm: (scope: BulkActionScope) => void;
};
export function BulkActionDialog({
open,
onOpenChange,
actionType,
seriesType,
currentNumber,
totalCount,
onConfirm,
}: BulkActionDialogProps) {
const [scope, setScope] = useState<BulkActionScope>("current");
const handleConfirm = () => {
onConfirm(scope);
onOpenChange(false);
};
const seriesLabel =
seriesType === "installment" ? "parcelamento" : "recorrência";
const actionLabel = actionType === "edit" ? "editar" : "remover";
const getDescription = () => {
if (seriesType === "installment" && currentNumber && totalCount) {
return `Este lançamento faz parte de um ${seriesLabel} (${currentNumber}/${totalCount}). Escolha o que deseja ${actionLabel}:`;
}
return `Este lançamento faz parte de uma ${seriesLabel}. Escolha o que deseja ${actionLabel}:`;
};
const getCurrentLabel = () => {
if (seriesType === "installment" && currentNumber) {
return `Apenas esta parcela (${currentNumber}/${totalCount})`;
}
return "Apenas este lançamento";
};
const getFutureLabel = () => {
if (seriesType === "installment" && currentNumber && totalCount) {
const remaining = totalCount - currentNumber + 1;
return `Esta e as próximas parcelas (${remaining} ${
remaining === 1 ? "parcela" : "parcelas"
})`;
}
return "Este e os próximos lançamentos";
};
const getAllLabel = () => {
if (seriesType === "installment" && totalCount) {
return `Todas as parcelas (${totalCount} ${
totalCount === 1 ? "parcela" : "parcelas"
})`;
}
return `Todos os lançamentos da ${seriesLabel}`;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="capitalize">
{actionLabel} {seriesLabel}
</DialogTitle>
<DialogDescription>{getDescription()}</DialogDescription>
</DialogHeader>
<RadioGroup
value={scope}
onValueChange={(v) => setScope(v as BulkActionScope)}
>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<RadioGroupItem value="current" id="current" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="current"
className="text-sm cursor-pointer font-medium"
>
{getCurrentLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração apenas neste lançamento
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="future" id="future" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="future"
className="text-sm cursor-pointer font-medium"
>
{getFutureLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração neste e nos próximos lançamentos da série
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<RadioGroupItem value="all" id="all" className="mt-0.5" />
<div className="flex-1">
<Label
htmlFor="all"
className="text-sm cursor-pointer font-medium"
>
{getAllLabel()}
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração em todos os lançamentos da série
</p>
</div>
</div>
</div>
</RadioGroup>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleConfirm}
variant={actionType === "delete" ? "destructive" : "default"}
>
Confirmar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,210 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
import {
currencyFormatter,
formatCondition,
formatDate,
formatPeriod,
getTransactionBadgeVariant,
} from "@/lib/lancamentos/formatting-helpers";
import { InstallmentTimeline } from "../shared/installment-timeline";
import type { LancamentoItem } from "../types";
interface LancamentoDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
lancamento: LancamentoItem | null;
}
export function LancamentoDetailsDialog({
open,
onOpenChange,
lancamento,
}: LancamentoDetailsDialogProps) {
if (!lancamento) return null;
const isInstallment =
lancamento.condition?.toLowerCase() === "parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount;
const valorParcela = Math.abs(lancamento.amount);
const totalParcelas = lancamento.installmentCount ?? 1;
const parcelaAtual = lancamento.currentInstallment ?? 1;
const valorTotal = isInstallment
? valorParcela * totalParcelas
: valorParcela;
const valorRestante = isInstallment
? valorParcela * (totalParcelas - parcelaAtual)
: 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 sm:max-w-xl">
<Card className="gap-2 space-y-4">
<CardHeader className="flex flex-row items-start border-b">
<div>
<DialogTitle className="group flex items-center gap-2 text-lg">
#{lancamento.id}
</DialogTitle>
<CardDescription>
{formatDate(lancamento.purchaseDate)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="text-sm">
<div className="grid gap-3">
<ul className="grid gap-3">
<DetailRow label="Descrição" value={lancamento.name} />
<DetailRow
label="Período"
value={formatPeriod(lancamento.period)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Forma de Pagamento
</span>
<span className="flex items-center gap-1.5">
{getPaymentMethodIcon(lancamento.paymentMethod)}
<span className="capitalize">
{lancamento.paymentMethod}
</span>
</span>
</li>
<DetailRow
label={lancamento.cartaoName ? "Cartão" : "Conta"}
value={lancamento.cartaoName ?? lancamento.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={lancamento.categoriaName ?? "—"}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Tipo de Transação
</span>
<span className="capitalize">
<Badge
variant={getTransactionBadgeVariant(
lancamento.transactionType
)}
>
{lancamento.transactionType}
</Badge>
</span>
</li>
<DetailRow
label="Condição"
value={formatCondition(lancamento.condition)}
/>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">Responsável</span>
<span className="flex items-center gap-2 capitalize">
<span>{lancamento.pagadorName}</span>
</span>
</li>
<DetailRow
label="Status"
value={lancamento.isSettled ? "Pago" : "Pendente"}
/>
{lancamento.note && (
<DetailRow label="Notas" value={lancamento.note} />
)}
</ul>
<ul className="mb-6 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={new Date(lancamento.purchaseDate)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={lancamento.period}
/>
</li>
)}
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{lancamento.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${lancamento.recurrenceCount} meses`}
/>
)}
{!isInstallment && <Separator className="my-2" />}
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">Total da Compra</span>
<span className="text-lg">
{currencyFormatter.format(valorTotal)}
</span>
</li>
</ul>
</div>
<DialogFooter>
<DialogClose asChild>
<Button className="w-full" type="button">
Entendi
</Button>
</DialogClose>
</DialogFooter>
</CardContent>
</Card>
</DialogContent>
</Dialog>
);
}
interface DetailRowProps {
label: string;
value: string;
}
function DetailRow({ label, value }: DetailRowProps) {
return (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="capitalize">{value}</span>
</li>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { Label } from "@/components/ui/label";
import { DatePicker } from "@/components/ui/date-picker";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CurrencyInput } from "@/components/ui/currency-input";
import { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog";
import { RiCalculatorLine } from "@remixicon/react";
import { EstabelecimentoInput } from "../../shared/estabelecimento-input";
import type { BasicFieldsSectionProps } from "./lancamento-dialog-types";
export function BasicFieldsSection({
formState,
onFieldChange,
estabelecimentos,
monthOptions,
}: BasicFieldsSectionProps) {
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>
<Select
value={formState.period}
onValueChange={(value) => onFieldChange("period", value)}
>
<SelectTrigger id="period" className="w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{monthOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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}
required
/>
</div>
<div className="w-1/2 space-y-1">
<Label htmlFor="amount">Valor</Label>
<div className="relative">
<CurrencyInput
id="amount"
value={formState.amount}
onValueChange={(value) => onFieldChange("amount", value)}
placeholder="R$ 0,00"
required
className="pr-10"
/>
<CalculatorDialogButton
variant="ghost"
size="icon-sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
>
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
</CalculatorDialogButton>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { Label } from "@/components/ui/label";
import { DatePicker } from "@/components/ui/date-picker";
import { cn } from "@/lib/utils/ui";
import type { BoletoFieldsSectionProps } from "./lancamento-dialog-types";
export function BoletoFieldsSection({
formState,
onFieldChange,
showPaymentDate,
}: BoletoFieldsSectionProps) {
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-2 w-full",
showPaymentDate ? "md:w-1/2" : "md:w-full"
)}
>
<Label htmlFor="dueDate">Vencimento do boleto</Label>
<DatePicker
id="dueDate"
value={formState.dueDate}
onChange={(value) => onFieldChange("dueDate", value)}
placeholder="Selecione o vencimento"
/>
</div>
{showPaymentDate ? (
<div className="space-y-2 w-full md:w-1/2">
<Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label>
<DatePicker
id="boletoPaymentDate"
value={formState.boletoPaymentDate}
onChange={(value) => onFieldChange("boletoPaymentDate", value)}
placeholder="Selecione a data de pagamento"
/>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_TRANSACTION_TYPES } from "@/lib/lancamentos/constants";
import { cn } from "@/lib/utils/ui";
import {
CategoriaSelectContent,
TransactionTypeSelectContent,
} from "../../select-items";
import type { CategorySectionProps } from "./lancamento-dialog-types";
export function CategorySection({
formState,
onFieldChange,
categoriaOptions,
categoriaGroups,
isUpdateMode,
}: CategorySectionProps) {
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
{!isUpdateMode ? (
<div className="w-full space-y-1 md:w-1/2">
<Label htmlFor="transactionType">Tipo de transação</Label>
<Select
value={formState.transactionType}
onValueChange={(value) => onFieldChange("transactionType", value)}
>
<SelectTrigger id="transactionType" className="w-full">
<SelectValue placeholder="Selecione">
{formState.transactionType && (
<TransactionTypeSelectContent
label={formState.transactionType}
/>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{LANCAMENTO_TRANSACTION_TYPES.filter(
(type) => type !== "Transferência"
).map((type) => (
<SelectItem key={type} value={type}>
<TransactionTypeSelectContent label={type} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div
className={cn(
"space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full"
)}
>
<Label htmlFor="categoria">Categoria</Label>
<Select
value={formState.categoriaId}
onValueChange={(value) => onFieldChange("categoriaId", value)}
>
<SelectTrigger id="categoria" className="w-full">
<SelectValue placeholder="Selecione">
{formState.categoriaId &&
(() => {
const selectedOption = categoriaOptions.find(
(opt) => opt.value === formState.categoriaId
);
return selectedOption ? (
<CategoriaSelectContent
label={selectedOption.label}
icon={selectedOption.icon}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categoriaGroups.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>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants";
import { cn } from "@/lib/utils/ui";
import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./lancamento-dialog-types";
export function ConditionSection({
formState,
onFieldChange,
showInstallments,
showRecurrence,
}: ConditionSectionProps) {
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-2 w-full",
showInstallments || showRecurrence ? "md:w-1/2" : "md:w-full"
)}
>
<Label htmlFor="condition">Condição</Label>
<Select
value={formState.condition}
onValueChange={(value) => onFieldChange("condition", value)}
>
<SelectTrigger id="condition" className="w-full">
<SelectValue placeholder="Selecione">
{formState.condition && (
<ConditionSelectContent label={formState.condition} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{LANCAMENTO_CONDITIONS.map((condition) => (
<SelectItem key={condition} value={condition}>
<ConditionSelectContent label={condition} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showInstallments ? (
<div className="space-y-2 w-full md:w-1/2">
<Label htmlFor="installmentCount">Parcelado em</Label>
<Select
value={formState.installmentCount}
onValueChange={(value) => onFieldChange("installmentCount", value)}
>
<SelectTrigger id="installmentCount" className="w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{[...Array(24)].map((_, index) => (
<SelectItem key={index + 2} value={String(index + 2)}>
{index + 2}x
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{showRecurrence ? (
<div className="space-y-2 w-full md:w-1/2">
<Label htmlFor="recurrenceCount">Lançamento fixo</Label>
<Select
value={formState.recurrenceCount}
onValueChange={(value) => onFieldChange("recurrenceCount", value)}
>
<SelectTrigger id="recurrenceCount" className="w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{[...Array(24)].map((_, index) => (
<SelectItem key={index + 2} value={String(index + 2)}>
Por {index + 2} meses
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import type { LancamentoFormState } from "@/lib/lancamentos/form-helpers";
import type { LancamentoItem, SelectOption } from "../../types";
export type FormState = LancamentoFormState;
export interface LancamentoDialogProps {
mode: "create" | "update";
trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId?: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
lancamento?: LancamentoItem;
defaultPeriod?: string;
defaultCartaoId?: string | null;
defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null;
lockCartaoSelection?: boolean;
lockPaymentMethod?: boolean;
onBulkEditRequest?: (data: {
id: string;
name: string;
categoriaId: string | undefined;
note: string;
pagadorId: string | undefined;
contaId: string | undefined;
cartaoId: string | undefined;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
}) => void;
}
export interface BaseFieldSectionProps {
formState: FormState;
onFieldChange: <Key extends keyof FormState>(
key: Key,
value: FormState[Key]
) => void;
}
export interface BasicFieldsSectionProps extends BaseFieldSectionProps {
estabelecimentos: string[];
monthOptions: Array<{ value: string; label: string }>;
}
export interface CategorySectionProps extends BaseFieldSectionProps {
categoriaOptions: SelectOption[];
categoriaGroups: Array<{
label: string;
options: SelectOption[];
}>;
isUpdateMode: boolean;
}
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
showSettledToggle: boolean;
}
export interface PagadorSectionProps extends BaseFieldSectionProps {
pagadorOptions: SelectOption[];
secondaryPagadorOptions: SelectOption[];
}
export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
isUpdateMode: boolean;
disablePaymentMethod: boolean;
disableCartaoSelect: boolean;
}
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
showPaymentDate: boolean;
}
export interface ConditionSectionProps extends BaseFieldSectionProps {
showInstallments: boolean;
showRecurrence: boolean;
}
export type NoteSectionProps = BaseFieldSectionProps;

View File

@@ -0,0 +1,421 @@
"use client";
import {
createLancamentoAction,
updateLancamentoAction,
} from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import {
filterSecondaryPagadorOptions,
groupAndSortCategorias,
} from "@/lib/lancamentos/categoria-helpers";
import {
applyFieldDependencies,
buildLancamentoInitialState,
} from "@/lib/lancamentos/form-helpers";
import { createMonthOptions } from "@/lib/utils/period";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { BasicFieldsSection } from "./basic-fields-section";
import { BoletoFieldsSection } from "./boleto-fields-section";
import { CategorySection } from "./category-section";
import { ConditionSection } from "./condition-section";
import type {
FormState,
LancamentoDialogProps,
} from "./lancamento-dialog-types";
import { NoteSection } from "./note-section";
import { PagadorSection } from "./pagador-section";
import { PaymentMethodSection } from "./payment-method-section";
import { SplitAndSettlementSection } from "./split-settlement-section";
export function LancamentoDialog({
mode,
trigger,
open,
onOpenChange,
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
lancamento,
defaultPeriod,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
lockCartaoSelection,
lockPaymentMethod,
onBulkEditRequest,
}: LancamentoDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange
);
const [formState, setFormState] = useState<FormState>(() =>
buildLancamentoInitialState(lancamento, defaultPagadorId, defaultPeriod, {
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
})
);
const [periodDirty, setPeriodDirty] = useState(false);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (dialogOpen) {
setFormState(
buildLancamentoInitialState(
lancamento,
defaultPagadorId,
defaultPeriod,
{
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
}
)
);
setErrorMessage(null);
setPeriodDirty(false);
}
}, [
dialogOpen,
lancamento,
defaultPagadorId,
defaultPeriod,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
]);
const primaryPagador = formState.pagadorId;
const secondaryPagadorOptions = useMemo(
() => filterSecondaryPagadorOptions(splitPagadorOptions, primaryPagador),
[splitPagadorOptions, primaryPagador]
);
const categoriaGroups = useMemo(() => {
const filtered = categoriaOptions.filter(
(option) =>
option.group?.toLowerCase() === formState.transactionType.toLowerCase()
);
return groupAndSortCategorias(filtered);
}, [categoriaOptions, formState.transactionType]);
const monthOptions = useMemo(
() => createMonthOptions(formState.period),
[formState.period]
);
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
);
return {
...prev,
[key]: value,
...dependencies,
};
});
},
[periodDirty]
);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (!formState.purchaseDate) {
const message = "Informe a data da transação.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!formState.name.trim()) {
const message = "Informe a descrição do lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.isSplit && !formState.pagadorId) {
const message =
"Selecione o pagador principal para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.isSplit && !formState.secondaryPagadorId) {
const message =
"Selecione o pagador secundário para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
const amountValue = Number(formState.amount);
if (Number.isNaN(amountValue)) {
const message = "Informe um valor válido.";
setErrorMessage(message);
toast.error(message);
return;
}
const sanitizedAmount = Math.abs(amountValue);
const payload = {
purchaseDate: formState.purchaseDate,
period: formState.period,
name: formState.name.trim(),
transactionType: formState.transactionType,
amount: sanitizedAmount,
condition: formState.condition,
paymentMethod: formState.paymentMethod,
pagadorId: formState.pagadorId,
secondaryPagadorId: formState.isSplit
? formState.secondaryPagadorId
: undefined,
isSplit: formState.isSplit,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
categoriaId: formState.categoriaId,
note: formState.note.trim() || undefined,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
installmentCount:
formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount)
: undefined,
recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount)
: undefined,
dueDate:
formState.paymentMethod === "Boleto" && formState.dueDate
? formState.dueDate
: undefined,
boletoPaymentDate:
mode === "update" &&
formState.paymentMethod === "Boleto" &&
formState.boletoPaymentDate
? formState.boletoPaymentDate
: undefined,
};
startTransition(async () => {
if (mode === "create") {
const result = await createLancamentoAction(payload);
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
return;
}
// Update mode
const hasSeriesId = Boolean(lancamento?.seriesId);
if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, abre o diálogo de bulk action
onBulkEditRequest({
id: lancamento?.id ?? "",
name: formState.name.trim(),
categoriaId: formState.categoriaId,
note: formState.note.trim() || "",
pagadorId: formState.pagadorId,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
amount: sanitizedAmount,
dueDate:
formState.paymentMethod === "Boleto"
? formState.dueDate || null
: null,
boletoPaymentDate:
mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null
: null,
});
return;
}
// Atualização normal para lançamentos únicos ou todos os campos
const result = await updateLancamentoAction({
id: lancamento?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[
formState,
mode,
lancamento?.id,
lancamento?.seriesId,
setDialogOpen,
onBulkEditRequest,
]
);
const title = mode === "create" ? "Novo lançamento" : "Editar lançamento";
const description =
mode === "create"
? "Informe os dados abaixo para registrar um novo lançamento."
: "Atualize as informações do lançamento selecionado.";
const submitLabel = mode === "create" ? "Salvar lançamento" : "Atualizar";
const showInstallments = formState.condition === "Parcelado";
const showRecurrence = formState.condition === "Recorrente";
const showDueDate = formState.paymentMethod === "Boleto";
const showPaymentDate = mode === "update" && showDueDate;
const showSettledToggle = formState.paymentMethod !== "Cartão de crédito";
const isUpdateMode = mode === "update";
const disablePaymentMethod = Boolean(lockPaymentMethod && mode === "create");
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>
<form
className="space-y-2 -mx-6 max-h-[80vh] overflow-y-auto px-6 pb-1"
onSubmit={handleSubmit}
>
<BasicFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
estabelecimentos={estabelecimentos}
monthOptions={monthOptions}
/>
<CategorySection
formState={formState}
onFieldChange={handleFieldChange}
categoriaOptions={categoriaOptions}
categoriaGroups={categoriaGroups}
isUpdateMode={isUpdateMode}
/>
{!isUpdateMode ? (
<SplitAndSettlementSection
formState={formState}
onFieldChange={handleFieldChange}
showSettledToggle={showSettledToggle}
/>
) : null}
<PagadorSection
formState={formState}
onFieldChange={handleFieldChange}
pagadorOptions={pagadorOptions}
secondaryPagadorOptions={secondaryPagadorOptions}
/>
<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>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { NoteSectionProps } from "./lancamento-dialog-types";
export function NoteSection({ formState, onFieldChange }: NoteSectionProps) {
return (
<div className="space-y-2">
<Label htmlFor="note">Anotação</Label>
<Textarea
id="note"
value={formState.note}
onChange={(event) => onFieldChange("note", event.target.value)}
placeholder="Adicione observações sobre o lançamento"
rows={2}
/>
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PagadorSelectContent } from "../../select-items";
import type { PagadorSectionProps } from "./lancamento-dialog-types";
export function PagadorSection({
formState,
onFieldChange,
pagadorOptions,
secondaryPagadorOptions,
}: PagadorSectionProps) {
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-full space-y-1">
<Label htmlFor="pagador">Pagador</Label>
<Select
value={formState.pagadorId}
onValueChange={(value) => onFieldChange("pagadorId", value)}
>
<SelectTrigger id="pagador" className="w-full">
<SelectValue placeholder="Selecione">
{formState.pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === formState.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>
{formState.isSplit ? (
<div className="w-full space-y-1">
<Label htmlFor="secondaryPagador">Dividir com</Label>
<Select
value={formState.secondaryPagadorId}
onValueChange={(value) =>
onFieldChange("secondaryPagadorId", value)
}
>
<SelectTrigger
id="secondaryPagador"
disabled={secondaryPagadorOptions.length === 0}
className={"w-full"}
>
<SelectValue placeholder="Selecione">
{formState.secondaryPagadorId &&
(() => {
const selectedOption = secondaryPagadorOptions.find(
(opt) => opt.value === formState.secondaryPagadorId
);
return selectedOption ? (
<PagadorSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{secondaryPagadorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,288 @@
"use client";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { cn } from "@/lib/utils/ui";
import {
PaymentMethodSelectContent,
ContaCartaoSelectContent,
} from "../../select-items";
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
export function PaymentMethodSection({
formState,
onFieldChange,
contaOptions,
cartaoOptions,
isUpdateMode,
disablePaymentMethod,
disableCartaoSelect,
}: PaymentMethodSectionProps) {
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
const showContaSelect = [
"Pix",
"Dinheiro",
"Boleto",
"Cartão de débito",
].includes(formState.paymentMethod);
return (
<>
{!isUpdateMode ? (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-1 w-full",
isCartaoSelected || showContaSelect ? "md:w-1/2" : "md:w-full"
)}
>
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
<Select
value={formState.paymentMethod}
onValueChange={(value) => onFieldChange("paymentMethod", value)}
disabled={disablePaymentMethod}
>
<SelectTrigger
id="paymentMethod"
className="w-full"
disabled={disablePaymentMethod}
>
<SelectValue placeholder="Selecione" className="w-full">
{formState.paymentMethod && (
<PaymentMethodSelectContent label={formState.paymentMethod} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{LANCAMENTO_PAYMENT_METHODS.map((method) => (
<SelectItem key={method} value={method}>
<PaymentMethodSelectContent label={method} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isCartaoSelected ? (
<div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="cartao">Cartão</Label>
<Select
value={formState.cartaoId}
onValueChange={(value) => onFieldChange("cartaoId", value)}
disabled={disableCartaoSelect}
>
<SelectTrigger
id="cartao"
className="w-full"
disabled={disableCartaoSelect}
>
<SelectValue placeholder="Selecione">
{formState.cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === formState.cartaoId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : null}
{!isCartaoSelected && showContaSelect ? (
<div
className={cn(
"space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full"
)}
>
<Label htmlFor="conta">Conta</Label>
<Select
value={formState.contaId}
onValueChange={(value) => onFieldChange("contaId", value)}
>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione">
{formState.contaId &&
(() => {
const selectedOption = contaOptions.find(
(opt) => opt.value === formState.contaId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : null}
</div>
) : null}
{isUpdateMode ? (
<div className="flex w-full flex-col gap-2 md:flex-row">
{isCartaoSelected ? (
<div
className={cn(
"space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full"
)}
>
<Label htmlFor="cartaoUpdate">Cartão</Label>
<Select
value={formState.cartaoId}
onValueChange={(value) => onFieldChange("cartaoId", value)}
>
<SelectTrigger id="cartaoUpdate" className="w-full">
<SelectValue placeholder="Selecione">
{formState.cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === formState.cartaoId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : null}
{!isCartaoSelected && showContaSelect ? (
<div
className={cn(
"space-y-1 w-full",
!isUpdateMode ? "md:w-1/2" : "md:w-full"
)}
>
<Label htmlFor="contaUpdate">Conta</Label>
<Select
value={formState.contaId}
onValueChange={(value) => onFieldChange("contaId", value)}
>
<SelectTrigger id="contaUpdate" className="w-full">
<SelectValue placeholder="Selecione">
{formState.contaId &&
(() => {
const selectedOption = contaOptions.find(
(opt) => opt.value === formState.contaId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : null}
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { cn } from "@/lib/utils/ui";
import { Checkbox } from "@/components/ui/checkbox";
import type { SplitAndSettlementSectionProps } from "./lancamento-dialog-types";
export function SplitAndSettlementSection({
formState,
onFieldChange,
showSettledToggle,
}: SplitAndSettlementSectionProps) {
return (
<div className="flex w-full flex-col gap-2 py-2 md:flex-row">
<div
className={cn(
"space-y-1",
showSettledToggle ? "md:w-1/2 md:pr-2" : "md:w-full"
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-foreground">Dividir lançamento</p>
<p className="text-xs text-muted-foreground">
Selecione para atribuir parte do valor a outro pagador.
</p>
</div>
<Checkbox
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
aria-label="Dividir lançamento"
/>
</div>
</div>
{showSettledToggle ? (
<div className="space-y-1 md:w-1/2 md:pr-2">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-foreground">Marcar como pago</p>
<p className="text-xs text-muted-foreground">
Indica que o lançamento foi pago ou recebido.
</p>
</div>
<Checkbox
checked={Boolean(formState.isSettled)}
onCheckedChange={(checked) =>
onFieldChange("isSettled", Boolean(checked))
}
aria-label="Marcar como concluído"
/>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,608 @@
"use client";
import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import { DatePicker } from "@/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
import { getTodayDateString } from "@/lib/utils/date";
import { createMonthOptions } from "@/lib/utils/period";
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { EstabelecimentoInput } from "../shared/estabelecimento-input";
import type { SelectOption } from "../../types";
import {
CategoriaSelectContent,
ConditionSelectContent,
ContaCartaoSelectContent,
PagadorSelectContent,
PaymentMethodSelectContent,
TransactionTypeSelectContent,
} from "../select-items";
interface MassAddDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: MassAddFormData) => Promise<void>;
pagadorOptions: SelectOption[];
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
estabelecimentos: string[];
selectedPeriod: string;
defaultPagadorId?: string | null;
}
export interface MassAddFormData {
fixedFields: {
transactionType?: string;
pagadorId?: string;
paymentMethod?: string;
condition?: string;
period?: string;
contaId?: string;
cartaoId?: string;
};
transactions: Array<{
purchaseDate: string;
name: string;
amount: string;
categoriaId?: string;
}>;
}
interface TransactionRow {
id: string;
purchaseDate: string;
name: string;
amount: string;
categoriaId: string | undefined;
}
export function MassAddDialog({
open,
onOpenChange,
onSubmit,
pagadorOptions,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
selectedPeriod,
defaultPagadorId,
}: MassAddDialogProps) {
const [loading, setLoading] = useState(false);
// Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] = useState<string>("Despesa");
const [pagadorId, setPagadorId] = useState<string | undefined>(
defaultPagadorId ?? undefined
);
const [paymentMethod, setPaymentMethod] = useState<string>(
LANCAMENTO_PAYMENT_METHODS[0]
);
const [condition, setCondition] = useState<string>("À vista");
const [period, setPeriod] = useState<string>(selectedPeriod);
const [contaId, setContaId] = useState<string | undefined>();
const [cartaoId, setCartaoId] = useState<string | undefined>();
// Transaction rows
const [transactions, setTransactions] = useState<TransactionRow[]>([
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoriaId: undefined,
},
]);
// Period options
const periodOptions = useMemo(
() => createMonthOptions(selectedPeriod, 3),
[selectedPeriod]
);
// Categorias agrupadas e filtradas por tipo de transação
const groupedCategorias = useMemo(() => {
const filtered = categoriaOptions.filter(
(option) => option.group?.toLowerCase() === transactionType.toLowerCase()
);
return groupAndSortCategorias(filtered);
}, [categoriaOptions, transactionType]);
const addTransaction = () => {
setTransactions([
...transactions,
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoriaId: undefined,
},
]);
};
const removeTransaction = (id: string) => {
if (transactions.length === 1) {
toast.error("É necessário ter pelo menos uma transação");
return;
}
setTransactions(transactions.filter((t) => t.id !== id));
};
const updateTransaction = (
id: string,
field: keyof TransactionRow,
value: string | undefined
) => {
setTransactions(
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t))
);
};
const handleSubmit = async () => {
// Validate conta/cartao selection
if (paymentMethod === "Cartão de crédito" && !cartaoId) {
toast.error("Selecione um cartão para continuar");
return;
}
if (paymentMethod !== "Cartão de crédito" && !contaId) {
toast.error("Selecione uma conta para continuar");
return;
}
// Validate transactions
const invalidTransactions = transactions.filter(
(t) => !t.name.trim() || !t.amount.trim() || !t.purchaseDate
);
if (invalidTransactions.length > 0) {
toast.error(
"Preencha todos os campos obrigatórios das transações (data, estabelecimento e valor)"
);
return;
}
// Build form data
const formData: MassAddFormData = {
fixedFields: {
transactionType,
pagadorId,
paymentMethod,
condition,
period,
contaId: paymentMethod !== "Cartão de crédito" ? contaId : undefined,
cartaoId: paymentMethod === "Cartão de crédito" ? cartaoId : undefined,
},
transactions: transactions.map((t) => ({
purchaseDate: t.purchaseDate,
name: t.name.trim(),
amount: t.amount.trim(),
categoriaId: t.categoriaId,
})),
};
setLoading(true);
try {
await onSubmit(formData);
onOpenChange(false);
// Reset form
setTransactionType("Despesa");
setPagadorId(defaultPagadorId ?? undefined);
setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]);
setCondition("À vista");
setPeriod(selectedPeriod);
setContaId(undefined);
setCartaoId(undefined);
setTransactions([
{
id: crypto.randomUUID(),
purchaseDate: getTodayDateString(),
name: "",
amount: "",
categoriaId: undefined,
},
]);
} catch (_error) {
// Error is handled by the onSubmit function
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl 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.
</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-3">
{/* 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>
{/* Pagador */}
<div className="space-y-2">
<Label htmlFor="pagador">Pagador</Label>
<Select value={pagadorId} onValueChange={setPagadorId}>
<SelectTrigger id="pagador" className="w-full">
<SelectValue placeholder="Selecione o pagador">
{pagadorId &&
(() => {
const selectedOption = pagadorOptions.find(
(opt) => opt.value === 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>
{/* 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") {
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>
{/* Condition */}
<div className="space-y-2">
<Label htmlFor="condition">Condição</Label>
<Select value={condition} onValueChange={setCondition} disabled>
<SelectTrigger id="condition" className="w-full">
<SelectValue>
{condition && (
<ConditionSelectContent label={condition} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="À vista">
<ConditionSelectContent label="À vista" />
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Period */}
<div className="space-y-2">
<Label htmlFor="period">Período</Label>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger id="period" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{periodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Conta/Cartao */}
<div className="space-y-2">
<Label htmlFor="conta-cartao">
{paymentMethod === "Cartão de crédito" ? "Cartão" : "Conta"}
</Label>
{paymentMethod === "Cartão de crédito" ? (
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger id="conta-cartao" className="w-full">
<SelectValue placeholder="Selecione o cartão">
{cartaoId &&
(() => {
const selectedOption = cartaoOptions.find(
(opt) => opt.value === cartaoId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta-cartao" className="w-full">
<SelectValue placeholder="Selecione a conta">
{contaId &&
(() => {
const selectedOption = contaOptions.find(
(opt) => opt.value === contaId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
<Separator />
{/* Transactions Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Transações</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={addTransaction}
>
<RiAddLine className="size-4" />
Adicionar linha
</Button>
</div>
<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"
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={`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-42 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"
onClick={() => removeTransaction(transaction.id)}
disabled={transactions.length === 1}
>
<RiDeleteBinLine className="size-4" />
<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>
);
}