refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,424 +1,424 @@
"use client";
import { RiLoader4Line } from "@remixicon/react";
import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
} from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { CategoryIcon } from "@/components/categorias/category-icon";
import { InstallmentSelectionTable } from "./installment-selection-table";
import MoneyValues from "@/components/money-values";
import { PeriodPicker } from "@/components/period-picker";
import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
Field,
FieldContent,
FieldGroup,
FieldLabel,
FieldLegend,
} from "@/components/ui/field";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { PeriodPicker } from "@/components/period-picker";
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";
import { InstallmentSelectionTable } from "./installment-selection-table";
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;
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;
anticipationPeriod: string;
discount: number;
pagadorId: string;
categoriaId: string;
note: string;
};
export function AnticipateInstallmentsDialog({
trigger,
seriesId,
lancamentoName,
categorias,
pagadores,
defaultPeriod,
open,
onOpenChange,
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[]>([]);
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 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: "",
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: 0,
pagadorId: "",
categoriaId: "",
note: "",
});
// Buscar parcelas elegíveis ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
setIsLoadingInstallments(true);
setSelectedIds([]);
setErrorMessage(null);
// 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);
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]);
// 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 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 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);
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 (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;
}
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;
}
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,
});
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]
);
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, totalAmount],
);
const handleCancel = useCallback(() => {
setDialogOpen(false);
}, [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>
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>
<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>
{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>
{/* 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>
<PeriodPicker
value={formState.anticipationPeriod}
onChange={(value) =>
updateField("anticipationPeriod", value)
}
disabled={isPending}
className="w-full"
/>
</FieldContent>
</Field>
<div className="grid gap-2 sm:grid-cols-2">
<Field className="gap-1">
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel>
<FieldContent>
<PeriodPicker
value={formState.anticipationPeriod}
onChange={(value) =>
updateField("anticipationPeriod", value)
}
disabled={isPending}
className="w-full"
/>
</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-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-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="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>
<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>
)}
{/* 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>
)}
{/* 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>
);
<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

@@ -1,135 +1,143 @@
"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 { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
import { getInstallmentAnticipationsAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { useControlledState } from "@/hooks/use-controlled-state";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { AnticipationCard } from "../../shared/anticipation-card";
interface AnticipationHistoryDialogProps {
trigger?: React.ReactNode;
seriesId: string;
lancamentoName: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onViewLancamento?: (lancamentoId: string) => void;
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,
trigger,
seriesId,
lancamentoName,
open,
onOpenChange,
onViewLancamento,
}: AnticipationHistoryDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [anticipations, setAnticipations] = useState<
InstallmentAnticipationWithRelations[]
>([]);
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
);
// 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]);
// Buscar antecipações ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
loadAnticipations();
}
}, [dialogOpen, loadAnticipations]);
const loadAnticipations = async () => {
setIsLoading(true);
const loadAnticipations = async () => {
setIsLoading(true);
try {
const result = await getInstallmentAnticipationsAction(seriesId);
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);
}
};
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();
};
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>
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>
<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>
);
{!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

@@ -1,155 +1,155 @@
"use client";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
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;
installments: EligibleInstallment[];
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
}
export function InstallmentSelectionTable({
installments,
selectedIds,
onSelectionChange,
installments,
selectedIds,
onSelectionChange,
}: InstallmentSelectionTableProps) {
const toggleSelection = (id: string) => {
const newSelection = selectedIds.includes(id)
? selectedIds.filter((selectedId) => selectedId !== id)
: [...selectedIds, id];
onSelectionChange(newSelection);
};
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 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 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 });
};
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>
);
}
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>
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>
);
{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

@@ -1,162 +1,162 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
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;
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,
open,
onOpenChange,
actionType,
seriesType,
currentNumber,
totalCount,
onConfirm,
}: BulkActionDialogProps) {
const [scope, setScope] = useState<BulkActionScope>("current");
const [scope, setScope] = useState<BulkActionScope>("current");
const handleConfirm = () => {
onConfirm(scope);
onOpenChange(false);
};
const handleConfirm = () => {
onConfirm(scope);
onOpenChange(false);
};
const seriesLabel =
seriesType === "installment" ? "parcelamento" : "recorrência";
const actionLabel = actionType === "edit" ? "editar" : "remover";
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 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 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 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}`;
};
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>
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>
<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="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>
<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>
);
<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

@@ -1,363 +1,380 @@
"use client";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
import { useCallback, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import type { LancamentoItem, SelectOption } from "../types";
import {
CategoriaSelectContent,
ContaCartaoSelectContent,
PagadorSelectContent,
CategoriaSelectContent,
ContaCartaoSelectContent,
PagadorSelectContent,
} from "../select-items";
import type { LancamentoItem, SelectOption } from "../types";
interface BulkImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
items: LancamentoItem[];
pagadorOptions: SelectOption[];
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
defaultPagadorId?: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
items: LancamentoItem[];
pagadorOptions: SelectOption[];
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
defaultPagadorId?: string | null;
}
export function BulkImportDialog({
open,
onOpenChange,
items,
pagadorOptions,
contaOptions,
cartaoOptions,
categoriaOptions,
defaultPagadorId,
open,
onOpenChange,
items,
pagadorOptions,
contaOptions,
cartaoOptions,
categoriaOptions,
defaultPagadorId,
}: BulkImportDialogProps) {
const [pagadorId, setPagadorId] = useState<string | undefined>(
defaultPagadorId ?? undefined
);
const [categoriaId, setCategoriaId] = useState<string | undefined>(undefined);
const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition();
const [pagadorId, setPagadorId] = useState<string | undefined>(
defaultPagadorId ?? undefined,
);
const [categoriaId, setCategoriaId] = useState<string | undefined>(undefined);
const [contaId, setContaId] = useState<string | undefined>(undefined);
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
const [isPending, startTransition] = useTransition();
// Reset form when dialog opens/closes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
},
[onOpenChange, defaultPagadorId]
);
// Reset form when dialog opens/closes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!newOpen) {
setPagadorId(defaultPagadorId ?? undefined);
setCategoriaId(undefined);
setContaId(undefined);
setCartaoId(undefined);
}
onOpenChange(newOpen);
},
[onOpenChange, defaultPagadorId],
);
const categoriaGroups = useMemo(() => {
// Get unique transaction types from items
const transactionTypes = new Set(items.map((item) => item.transactionType));
const categoriaGroups = useMemo(() => {
// Get unique transaction types from items
const transactionTypes = new Set(items.map((item) => item.transactionType));
// Filter categories based on transaction types
const filtered = categoriaOptions.filter((option) => {
if (!option.group) return false;
return Array.from(transactionTypes).some(
(type) => option.group?.toLowerCase() === type.toLowerCase()
);
});
// Filter categories based on transaction types
const filtered = categoriaOptions.filter((option) => {
if (!option.group) return false;
return Array.from(transactionTypes).some(
(type) => option.group?.toLowerCase() === type.toLowerCase(),
);
});
return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]);
return groupAndSortCategorias(filtered);
}, [categoriaOptions, items]);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!pagadorId) {
toast.error("Selecione o pagador.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
if (!categoriaId) {
toast.error("Selecione a categoria.");
return;
}
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
startTransition(async () => {
let successCount = 0;
let errorCount = 0;
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
for (const item of items) {
const sanitizedAmount = Math.abs(item.amount);
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Determine payment method based on original item
const isCredit = item.paymentMethod === "Cartão de crédito";
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
// Validate payment method fields
if (isCredit && !cartaoId) {
toast.error("Selecione um cartão de crédito.");
return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
if (!isCredit && !contaId) {
toast.error("Selecione uma conta.");
return;
}
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as "Despesa" | "Receita" | "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as "Cartão de crédito" | "Cartão de débito" | "Pix" | "Dinheiro" | "Boleto" | "Pré-Pago | VR/VA" | "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const payload = {
purchaseDate: item.purchaseDate,
period: item.period,
name: item.name,
transactionType: item.transactionType as
| "Despesa"
| "Receita"
| "Transferência",
amount: sanitizedAmount,
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
paymentMethod: item.paymentMethod as
| "Cartão de crédito"
| "Cartão de débito"
| "Pix"
| "Dinheiro"
| "Boleto"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
pagadorId,
secondaryPagadorId: undefined,
isSplit: false,
contaId: isCredit ? undefined : contaId,
cartaoId: isCredit ? cartaoId : undefined,
categoriaId,
note: item.note || undefined,
isSettled: isCredit ? null : Boolean(item.isSettled),
installmentCount:
item.condition === "Parcelado" && item.installmentCount
? Number(item.installmentCount)
: undefined,
recurrenceCount:
item.condition === "Recorrente" && item.recurrenceCount
? Number(item.recurrenceCount)
: undefined,
dueDate:
item.paymentMethod === "Boleto" && item.dueDate
? item.dueDate
: undefined,
};
const result = await createLancamentoAction(payload as any);
const result = await createLancamentoAction(payload as any);
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
}
if (result.success) {
successCount++;
} else {
errorCount++;
console.error(`Failed to import ${item.name}:`, result.error);
}
}
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1 ? "lançamento importado" : "lançamentos importados"
} com sucesso!`
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange]
);
if (errorCount === 0) {
toast.success(
`${successCount} ${
successCount === 1
? "lançamento importado"
: "lançamentos importados"
} com sucesso!`,
);
handleOpenChange(false);
} else if (successCount > 0) {
toast.warning(
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
);
} else {
toast.error("Falha ao importar lançamentos. Verifique o console.");
}
});
},
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange],
);
const itemCount = items.length;
const hasCredit = items.some((item) => item.paymentMethod === "Cartão de crédito");
const hasNonCredit = items.some((item) => item.paymentMethod !== "Cartão de crédito");
const itemCount = items.length;
const hasCredit = items.some(
(item) => item.paymentMethod === "Cartão de crédito",
);
const hasNonCredit = items.some(
(item) => item.paymentMethod !== "Cartão de crédito",
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Importar Lançamentos</DialogTitle>
<DialogDescription>
Importando {itemCount} {itemCount === 1 ? "lançamento" : "lançamentos"}.
Selecione o pagador, categoria e forma de pagamento para aplicar a todos.
</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Importar Lançamentos</DialogTitle>
<DialogDescription>
Importando {itemCount}{" "}
{itemCount === 1 ? "lançamento" : "lançamentos"}. Selecione o
pagador, categoria e forma de pagamento para aplicar a todos.
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<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>
<form className="space-y-4" onSubmit={handleSubmit}>
<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>
<div className="space-y-2">
<Label htmlFor="categoria">Categoria *</Label>
<Select value={categoriaId} onValueChange={setCategoriaId}>
<SelectTrigger id="categoria" className="w-full">
<SelectValue placeholder="Selecione a categoria">
{categoriaId &&
(() => {
const selectedOption = categoriaOptions.find(
(opt) => opt.value === 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 className="space-y-2">
<Label htmlFor="categoria">Categoria *</Label>
<Select value={categoriaId} onValueChange={setCategoriaId}>
<SelectTrigger id="categoria" className="w-full">
<SelectValue placeholder="Selecione a categoria">
{categoriaId &&
(() => {
const selectedOption = categoriaOptions.find(
(opt) => opt.value === 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>
{hasNonCredit && (
<div className="space-y-2">
<Label htmlFor="conta">
Conta {hasCredit ? "(para não cartão)" : "*"}
</Label>
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta" 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>
)}
{hasNonCredit && (
<div className="space-y-2">
<Label htmlFor="conta">
Conta {hasCredit ? "(para não cartão)" : "*"}
</Label>
<Select value={contaId} onValueChange={setContaId}>
<SelectTrigger id="conta" 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>
)}
{hasCredit && (
<div className="space-y-2">
<Label htmlFor="cartao">
Cartão {hasNonCredit ? "(para cartão de crédito)" : "*"}
</Label>
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger id="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>
</div>
)}
{hasCredit && (
<div className="space-y-2">
<Label htmlFor="cartao">
Cartão {hasNonCredit ? "(para cartão de crédito)" : "*"}
</Label>
<Select value={cartaoId} onValueChange={setCartaoId}>
<SelectTrigger id="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>
</div>
)}
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Importando..." : "Importar"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Importando..." : "Importar"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,19 +4,19 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTitle,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
currencyFormatter,
formatCondition,
formatDate,
formatPeriod,
getTransactionBadgeVariant,
currencyFormatter,
formatCondition,
formatDate,
formatPeriod,
getTransactionBadgeVariant,
} from "@/lib/lancamentos/formatting-helpers";
import { parseLocalDateString } from "@/lib/utils/date";
import { getPaymentMethodIcon } from "@/lib/utils/icons";
@@ -24,189 +24,189 @@ import { InstallmentTimeline } from "../shared/installment-timeline";
import type { LancamentoItem } from "../types";
interface LancamentoDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
lancamento: LancamentoItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
lancamento: LancamentoItem | null;
}
export function LancamentoDetailsDialog({
open,
onOpenChange,
lancamento,
open,
onOpenChange,
lancamento,
}: LancamentoDetailsDialogProps) {
if (!lancamento) return null;
if (!lancamento) return null;
const isInstallment =
lancamento.condition?.toLowerCase() === "parcelado" &&
lancamento.currentInstallment &&
lancamento.installmentCount;
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;
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">
<div className="gap-2 space-y-4 py-6">
<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>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 sm:max-w-xl">
<div className="gap-2 space-y-4 py-6">
<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} />
<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)}
/>
<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>
<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={lancamento.cartaoName ? "Cartão" : "Conta"}
value={lancamento.cartaoName ?? lancamento.contaName ?? "—"}
/>
<DetailRow
label="Categoria"
value={lancamento.categoriaName ?? "—"}
/>
<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.categoriaName === "Saldo inicial"
? "Saldo inicial"
: lancamento.transactionType,
)}
>
{lancamento.categoriaName === "Saldo inicial"
? "Saldo Inicial"
: lancamento.transactionType}
</Badge>
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
Tipo de Transação
</span>
<span className="capitalize">
<Badge
variant={getTransactionBadgeVariant(
lancamento.categoriaName === "Saldo inicial"
? "Saldo inicial"
: lancamento.transactionType,
)}
>
{lancamento.categoriaName === "Saldo inicial"
? "Saldo Inicial"
: lancamento.transactionType}
</Badge>
</span>
</li>
<DetailRow
label="Condição"
value={formatCondition(lancamento.condition)}
/>
<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>
<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"}
/>
<DetailRow
label="Status"
value={lancamento.isSettled ? "Pago" : "Pendente"}
/>
{lancamento.note && (
<DetailRow label="Notas" value={lancamento.note} />
)}
</ul>
{lancamento.note && (
<DetailRow label="Notas" value={lancamento.note} />
)}
</ul>
<ul className="mb-6 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
lancamento.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={lancamento.period}
/>
</li>
)}
<ul className="mb-6 grid gap-3">
{isInstallment && (
<li className="mt-4">
<InstallmentTimeline
purchaseDate={parseLocalDateString(
lancamento.purchaseDate,
)}
currentInstallment={parcelaAtual}
totalInstallments={totalParcelas}
period={lancamento.period}
/>
</li>
)}
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
<DetailRow
label={isInstallment ? "Valor da Parcela" : "Valor"}
value={currencyFormatter.format(valorParcela)}
/>
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{isInstallment && (
<DetailRow
label="Valor Restante"
value={currencyFormatter.format(valorRestante)}
/>
)}
{lancamento.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${lancamento.recurrenceCount} meses`}
/>
)}
{lancamento.recurrenceCount && (
<DetailRow
label="Quantidade de Recorrências"
value={`${lancamento.recurrenceCount} meses`}
/>
)}
{!isInstallment && <Separator className="my-2" />}
{!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>
<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>
</div>
</DialogContent>
</Dialog>
);
<DialogFooter>
<DialogClose asChild>
<Button className="w-full" type="button">
Entendi
</Button>
</DialogClose>
</DialogFooter>
</CardContent>
</div>
</DialogContent>
</Dialog>
);
}
interface DetailRowProps {
label: string;
value: string;
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>
);
return (
<li className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="capitalize">{value}</span>
</li>
);
}

View File

@@ -1,78 +1,78 @@
"use client";
import { Label } from "@/components/ui/label";
import { DatePicker } from "@/components/ui/date-picker";
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 { CalculatorDialogButton } from "@/components/calculadora/calculator-dialog";
import { RiCalculatorLine } from "@remixicon/react";
import { DatePicker } from "@/components/ui/date-picker";
import { Label } from "@/components/ui/label";
import { EstabelecimentoInput } from "../../shared/estabelecimento-input";
import type { BasicFieldsSectionProps } from "./lancamento-dialog-types";
export function BasicFieldsSection({
formState,
onFieldChange,
estabelecimentos,
formState,
onFieldChange,
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>
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>
<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>
<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="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>
</>
);
<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

@@ -1,42 +1,42 @@
"use client";
import { Label } from "@/components/ui/label";
import { DatePicker } from "@/components/ui/date-picker";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils/ui";
import type { BoletoFieldsSectionProps } from "./lancamento-dialog-types";
export function BoletoFieldsSection({
formState,
onFieldChange,
showPaymentDate,
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>
);
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

@@ -2,107 +2,107 @@
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
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,
CategoriaSelectContent,
TransactionTypeSelectContent,
} from "../../select-items";
import type { CategorySectionProps } from "./lancamento-dialog-types";
export function CategorySection({
formState,
onFieldChange,
categoriaOptions,
categoriaGroups,
isUpdateMode,
hideTransactionType = false,
formState,
onFieldChange,
categoriaOptions,
categoriaGroups,
isUpdateMode,
hideTransactionType = false,
}: CategorySectionProps) {
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
{showTransactionTypeField ? (
<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}
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
{showTransactionTypeField ? (
<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",
showTransactionTypeField ? "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>
);
<div
className={cn(
"space-y-1 w-full",
showTransactionTypeField ? "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

@@ -1,153 +1,158 @@
"use client";
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants";
import { cn } from "@/lib/utils/ui";
import { useMemo } from "react";
import { ConditionSelectContent } from "../../select-items";
import type { ConditionSectionProps } from "./lancamento-dialog-types";
function formatCurrency(value: number): string {
return value.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return value.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ConditionSection({
formState,
onFieldChange,
showInstallments,
showRecurrence,
formState,
onFieldChange,
showInstallments,
showRecurrence,
}: ConditionSectionProps) {
const amount = useMemo(() => {
const value = Number(formState.amount);
return Number.isNaN(value) || value <= 0 ? null : value;
}, [formState.amount]);
const amount = useMemo(() => {
const value = Number(formState.amount);
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 = (count: number) => {
if (amount) {
const installmentValue = amount / count;
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
}
return `${count}x`;
};
const getRecurrenceLabel = (count: number) => {
return `${count} meses`;
};
const _getRecurrenceLabel = (count: number) => {
return `${count} meses`;
};
const installmentSummary = useMemo(() => {
if (!showInstallments || !formState.installmentCount || !amount) {
return null;
}
const installmentSummary = useMemo(() => {
if (!showInstallments || !formState.installmentCount || !amount) {
return null;
}
const count = Number(formState.installmentCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
const count = Number(formState.installmentCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
return getInstallmentLabel(count);
}, [showInstallments, formState.installmentCount, amount]);
return getInstallmentLabel(count);
}, [
showInstallments,
formState.installmentCount,
amount,
getInstallmentLabel,
]);
const recurrenceSummary = useMemo(() => {
if (!showRecurrence || !formState.recurrenceCount) {
return null;
}
const recurrenceSummary = useMemo(() => {
if (!showRecurrence || !formState.recurrenceCount) {
return null;
}
const count = Number(formState.recurrenceCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
const count = Number(formState.recurrenceCount);
if (Number.isNaN(count) || count <= 0) {
return null;
}
return `Por ${count} meses`;
}, [showRecurrence, formState.recurrenceCount]);
return `Por ${count} meses`;
}, [showRecurrence, formState.recurrenceCount]);
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-1 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>
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-1 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-1 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">
{installmentSummary}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[...Array(24)].map((_, index) => {
const count = index + 2;
return (
<SelectItem key={count} value={String(count)}>
{getInstallmentLabel(count)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
) : null}
{showInstallments ? (
<div className="space-y-1 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">
{installmentSummary}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[...Array(24)].map((_, index) => {
const count = index + 2;
return (
<SelectItem key={count} value={String(count)}>
{getInstallmentLabel(count)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
) : null}
{showRecurrence ? (
<div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="recurrenceCount">Repetirá</Label>
<Select
value={formState.recurrenceCount}
onValueChange={(value) => onFieldChange("recurrenceCount", value)}
>
<SelectTrigger id="recurrenceCount" className="w-full">
<SelectValue placeholder="Selecione">
{recurrenceSummary}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[...Array(47)].map((_, index) => (
<SelectItem key={index + 2} value={String(index + 2)}>
{index + 2} meses
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
);
{showRecurrence ? (
<div className="space-y-1 w-full md:w-1/2">
<Label htmlFor="recurrenceCount">Repetirá</Label>
<Select
value={formState.recurrenceCount}
onValueChange={(value) => onFieldChange("recurrenceCount", value)}
>
<SelectTrigger id="recurrenceCount" className="w-full">
<SelectValue placeholder="Selecione">
{recurrenceSummary}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[...Array(47)].map((_, index) => (
<SelectItem key={index + 2} value={String(index + 2)}>
{index + 2} meses
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
);
}

View File

@@ -4,92 +4,92 @@ 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;
defaultName?: string | null;
defaultAmount?: string | null;
lockCartaoSelection?: boolean;
lockPaymentMethod?: boolean;
isImporting?: boolean;
defaultTransactionType?: "Despesa" | "Receita";
/** Force showing transaction type select even when defaultTransactionType is set */
forceShowTransactionType?: boolean;
/** Called after successful create/update. Receives the action result. */
onSuccess?: () => void;
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;
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;
defaultName?: string | null;
defaultAmount?: string | null;
lockCartaoSelection?: boolean;
lockPaymentMethod?: boolean;
isImporting?: boolean;
defaultTransactionType?: "Despesa" | "Receita";
/** Force showing transaction type select even when defaultTransactionType is set */
forceShowTransactionType?: boolean;
/** Called after successful create/update. Receives the action result. */
onSuccess?: () => void;
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;
formState: FormState;
onFieldChange: <Key extends keyof FormState>(
key: Key,
value: FormState[Key],
) => void;
}
export interface BasicFieldsSectionProps extends BaseFieldSectionProps {
estabelecimentos: string[];
estabelecimentos: string[];
}
export interface CategorySectionProps extends BaseFieldSectionProps {
categoriaOptions: SelectOption[];
categoriaGroups: Array<{
label: string;
options: SelectOption[];
}>;
isUpdateMode: boolean;
hideTransactionType?: boolean;
categoriaOptions: SelectOption[];
categoriaGroups: Array<{
label: string;
options: SelectOption[];
}>;
isUpdateMode: boolean;
hideTransactionType?: boolean;
}
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
showSettledToggle: boolean;
showSettledToggle: boolean;
}
export interface PagadorSectionProps extends BaseFieldSectionProps {
pagadorOptions: SelectOption[];
secondaryPagadorOptions: SelectOption[];
pagadorOptions: SelectOption[];
secondaryPagadorOptions: SelectOption[];
}
export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
isUpdateMode: boolean;
disablePaymentMethod: boolean;
disableCartaoSelect: boolean;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
isUpdateMode: boolean;
disablePaymentMethod: boolean;
disableCartaoSelect: boolean;
}
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
showPaymentDate: boolean;
showPaymentDate: boolean;
}
export interface ConditionSectionProps extends BaseFieldSectionProps {
showInstallments: boolean;
showRecurrence: boolean;
showInstallments: boolean;
showRecurrence: boolean;
}
export type NoteSectionProps = BaseFieldSectionProps;

View File

@@ -1,42 +1,42 @@
"use client";
import {
createLancamentoAction,
updateLancamentoAction,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createLancamentoAction,
updateLancamentoAction,
} from "@/app/(dashboard)/lancamentos/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import {
filterSecondaryPagadorOptions,
groupAndSortCategorias,
filterSecondaryPagadorOptions,
groupAndSortCategorias,
} from "@/lib/lancamentos/categoria-helpers";
import {
applyFieldDependencies,
buildLancamentoInitialState,
applyFieldDependencies,
buildLancamentoInitialState,
} from "@/lib/lancamentos/form-helpers";
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,
FormState,
LancamentoDialogProps,
} from "./lancamento-dialog-types";
import { NoteSection } from "./note-section";
import { PagadorSection } from "./pagador-section";
@@ -44,415 +44,417 @@ 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,
defaultName,
defaultAmount,
lockCartaoSelection,
lockPaymentMethod,
isImporting = false,
defaultTransactionType,
forceShowTransactionType = false,
onSuccess,
onBulkEditRequest,
mode,
trigger,
open,
onOpenChange,
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
estabelecimentos,
lancamento,
defaultPeriod,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
lockCartaoSelection,
lockPaymentMethod,
isImporting = false,
defaultTransactionType,
forceShowTransactionType = false,
onSuccess,
onBulkEditRequest,
}: LancamentoDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const [formState, setFormState] = useState<FormState>(() =>
buildLancamentoInitialState(lancamento, defaultPagadorId, defaultPeriod, {
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
}),
);
const [periodDirty, setPeriodDirty] = useState(false);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [formState, setFormState] = useState<FormState>(() =>
buildLancamentoInitialState(lancamento, defaultPagadorId, defaultPeriod, {
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
}),
);
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,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
},
),
);
setErrorMessage(null);
setPeriodDirty(false);
}
}, [
dialogOpen,
lancamento,
defaultPagadorId,
defaultPeriod,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
]);
useEffect(() => {
if (dialogOpen) {
setFormState(
buildLancamentoInitialState(
lancamento,
defaultPagadorId,
defaultPeriod,
{
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
},
),
);
setErrorMessage(null);
setPeriodDirty(false);
}
}, [
dialogOpen,
lancamento,
defaultPagadorId,
defaultPeriod,
defaultCartaoId,
defaultPaymentMethod,
defaultPurchaseDate,
defaultName,
defaultAmount,
defaultTransactionType,
isImporting,
]);
const primaryPagador = formState.pagadorId;
const primaryPagador = formState.pagadorId;
const secondaryPagadorOptions = useMemo(
() => filterSecondaryPagadorOptions(splitPagadorOptions, primaryPagador),
[splitPagadorOptions, primaryPagador],
);
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 categoriaGroups = useMemo(() => {
const filtered = categoriaOptions.filter(
(option) =>
option.group?.toLowerCase() === formState.transactionType.toLowerCase(),
);
return groupAndSortCategorias(filtered);
}, [categoriaOptions, formState.transactionType]);
const handleFieldChange = useCallback(
<Key extends keyof FormState>(key: Key, value: FormState[Key]) => {
if (key === "period") {
setPeriodDirty(true);
}
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,
);
setFormState((prev) => {
const dependencies = applyFieldDependencies(
key,
value,
prev,
periodDirty,
);
return {
...prev,
[key]: value,
...dependencies,
};
});
},
[periodDirty],
);
return {
...prev,
[key]: value,
...dependencies,
};
});
},
[periodDirty],
);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
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.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.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.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;
}
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 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 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,
};
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);
startTransition(async () => {
if (mode === "create") {
const result = await createLancamentoAction(payload);
if (result.success) {
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
return;
}
if (result.success) {
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
return;
}
// Update mode
const hasSeriesId = Boolean(lancamento?.seriesId);
// 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;
}
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,
});
// 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);
onSuccess?.();
setDialogOpen(false);
return;
}
if (result.success) {
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[
formState,
mode,
lancamento?.id,
lancamento?.seriesId,
setDialogOpen,
onSuccess,
onBulkEditRequest,
],
);
setErrorMessage(result.error);
toast.error(result.error);
});
},
[
formState,
mode,
lancamento?.id,
lancamento?.seriesId,
setDialogOpen,
onSuccess,
onBulkEditRequest,
],
);
const isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting;
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;
const isNewWithType =
mode === "create" && !lancamento && defaultTransactionType;
const isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting;
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;
const isNewWithType =
mode === "create" && !lancamento && defaultTransactionType;
const title =
mode === "create"
? isImportMode
? "Importar para Minha Conta"
: isCopyMode
? "Copiar lançamento"
: isNewWithType
? defaultTransactionType === "Despesa"
? "Nova Despesa"
: "Nova Receita"
: "Novo lançamento"
: "Editar lançamento";
const description =
mode === "create"
? isImportMode
? "Importando lançamento de outro usuário. Ajuste a categoria, pagador e cartão/conta antes de salvar."
: isCopyMode
? "Os dados do lançamento foram copiados. Revise e ajuste conforme necessário antes de salvar."
: isNewWithType
? `Informe os dados abaixo para registrar ${defaultTransactionType === "Despesa" ? "uma nova despesa" : "uma nova receita"}.`
: "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 title =
mode === "create"
? isImportMode
? "Importar para Minha Conta"
: isCopyMode
? "Copiar lançamento"
: isNewWithType
? defaultTransactionType === "Despesa"
? "Nova Despesa"
: "Nova Receita"
: "Novo lançamento"
: "Editar lançamento";
const description =
mode === "create"
? isImportMode
? "Importando lançamento de outro usuário. Ajuste a categoria, pagador e cartão/conta antes de salvar."
: isCopyMode
? "Os dados do lançamento foram copiados. Revise e ajuste conforme necessário antes de salvar."
: isNewWithType
? `Informe os dados abaixo para registrar ${defaultTransactionType === "Despesa" ? "uma nova despesa" : "uma nova receita"}.`
: "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");
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>
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}
/>
<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}
/>
<CategorySection
formState={formState}
onFieldChange={handleFieldChange}
categoriaOptions={categoriaOptions}
categoriaGroups={categoriaGroups}
isUpdateMode={isUpdateMode}
hideTransactionType={Boolean(isNewWithType) && !forceShowTransactionType}
/>
<CategorySection
formState={formState}
onFieldChange={handleFieldChange}
categoriaOptions={categoriaOptions}
categoriaGroups={categoriaGroups}
isUpdateMode={isUpdateMode}
hideTransactionType={
Boolean(isNewWithType) && !forceShowTransactionType
}
/>
{!isUpdateMode ? (
<SplitAndSettlementSection
formState={formState}
onFieldChange={handleFieldChange}
showSettledToggle={showSettledToggle}
/>
) : null}
{!isUpdateMode ? (
<SplitAndSettlementSection
formState={formState}
onFieldChange={handleFieldChange}
showSettledToggle={showSettledToggle}
/>
) : null}
<PagadorSection
formState={formState}
onFieldChange={handleFieldChange}
pagadorOptions={pagadorOptions}
secondaryPagadorOptions={secondaryPagadorOptions}
/>
<PagadorSection
formState={formState}
onFieldChange={handleFieldChange}
pagadorOptions={pagadorOptions}
secondaryPagadorOptions={secondaryPagadorOptions}
/>
<PaymentMethodSection
formState={formState}
onFieldChange={handleFieldChange}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
isUpdateMode={isUpdateMode}
disablePaymentMethod={disablePaymentMethod}
disableCartaoSelect={disableCartaoSelect}
/>
<PaymentMethodSection
formState={formState}
onFieldChange={handleFieldChange}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
isUpdateMode={isUpdateMode}
disablePaymentMethod={disablePaymentMethod}
disableCartaoSelect={disableCartaoSelect}
/>
{showDueDate ? (
<BoletoFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
showPaymentDate={showPaymentDate}
/>
) : null}
{showDueDate ? (
<BoletoFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
showPaymentDate={showPaymentDate}
/>
) : null}
{!isUpdateMode ? (
<ConditionSection
formState={formState}
onFieldChange={handleFieldChange}
showInstallments={showInstallments}
showRecurrence={showRecurrence}
/>
) : null}
{!isUpdateMode ? (
<ConditionSection
formState={formState}
onFieldChange={handleFieldChange}
showInstallments={showInstallments}
showRecurrence={showRecurrence}
/>
) : null}
<NoteSection
formState={formState}
onFieldChange={handleFieldChange}
/>
<NoteSection
formState={formState}
onFieldChange={handleFieldChange}
/>
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
{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>
);
<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

@@ -5,17 +5,17 @@ import { Textarea } from "@/components/ui/textarea";
import type { NoteSectionProps } from "./lancamento-dialog-types";
export function NoteSection({ formState, onFieldChange }: NoteSectionProps) {
return (
<div className="space-y-1">
<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}
className="min-h-[36px] resize-none"
/>
</div>
);
return (
<div className="space-y-1">
<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}
className="min-h-[36px] resize-none"
/>
</div>
);
}

View File

@@ -2,100 +2,100 @@
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
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,
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>
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>
);
{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

@@ -2,299 +2,299 @@
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
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,
ContaCartaoSelectContent,
PaymentMethodSelectContent,
} from "../../select-items";
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
export function PaymentMethodSection({
formState,
onFieldChange,
contaOptions,
cartaoOptions,
isUpdateMode,
disablePaymentMethod,
disableCartaoSelect,
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",
"Pré-Pago | VR/VA",
"Transferência bancária",
].includes(formState.paymentMethod);
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
const showContaSelect = [
"Pix",
"Dinheiro",
"Boleto",
"Cartão de débito",
"Pré-Pago | VR/VA",
"Transferência bancária",
].includes(formState.paymentMethod);
// Filtrar contas apenas do tipo "Pré-Pago | VR/VA" quando forma de pagamento for "Pré-Pago | VR/VA"
const filteredContaOptions =
formState.paymentMethod === "Pré-Pago | VR/VA"
? contaOptions.filter(
(option) => option.accountType === "Pré-Pago | VR/VA"
)
: contaOptions;
// Filtrar contas apenas do tipo "Pré-Pago | VR/VA" quando forma de pagamento for "Pré-Pago | VR/VA"
const filteredContaOptions =
formState.paymentMethod === "Pré-Pago | VR/VA"
? contaOptions.filter(
(option) => option.accountType === "Pré-Pago | VR/VA",
)
: contaOptions;
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>
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 ? (
<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 = filteredContaOptions.find(
(opt) => opt.value === formState.contaId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.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}
{!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 = filteredContaOptions.find(
(opt) => opt.value === formState.contaId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.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}
{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 = filteredContaOptions.find(
(opt) => opt.value === formState.contaId
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.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}
</>
);
{!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 = filteredContaOptions.find(
(opt) => opt.value === formState.contaId,
);
return selectedOption ? (
<ContaCartaoSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.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

@@ -1,58 +1,58 @@
"use client";
import { cn } from "@/lib/utils/ui";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils/ui";
import type { SplitAndSettlementSectionProps } from "./lancamento-dialog-types";
export function SplitAndSettlementSection({
formState,
onFieldChange,
showSettledToggle,
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>
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>
);
{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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,21 @@
// Main page component
export { default as LancamentosPage } from "./page/lancamentos-page";
// Table components
export { default as LancamentosTable } from "./table/lancamentos-table";
export { default as LancamentosFilters } from "./table/lancamentos-filters";
export { default as AnticipateInstallmentsDialog } from "./dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
export { default as BulkActionDialog } from "./dialogs/bulk-action-dialog";
export { default as LancamentoDetailsDialog } from "./dialogs/lancamento-details-dialog";
// Main dialogs
export { default as LancamentoDialog } from "./dialogs/lancamento-dialog/lancamento-dialog";
export { default as LancamentoDetailsDialog } from "./dialogs/lancamento-details-dialog";
export type * from "./dialogs/lancamento-dialog/lancamento-dialog-types";
export { default as MassAddDialog } from "./dialogs/mass-add-dialog";
export { default as BulkActionDialog } from "./dialogs/bulk-action-dialog";
export { default as AnticipateInstallmentsDialog } from "./dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
export { default as LancamentosPage } from "./page/lancamentos-page";
export * from "./select-items";
export { default as AnticipationCard } from "./shared/anticipation-card";
// Shared components
export { default as EstabelecimentoInput } from "./shared/estabelecimento-input";
export { default as InstallmentTimeline } from "./shared/installment-timeline";
export { default as AnticipationCard } from "./shared/anticipation-card";
export { default as LancamentosFilters } from "./table/lancamentos-filters";
// Table components
export { default as LancamentosTable } from "./table/lancamentos-table";
// Types and utilities
export type * from "./types";
export type * from "./dialogs/lancamento-dialog/lancamento-dialog-types";
export * from "./select-items";

View File

@@ -1,308 +1,308 @@
"use client";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
import {
RiDownloadLine,
RiFileExcelLine,
RiFilePdfLine,
RiFileTextLine,
RiDownloadLine,
RiFileExcelLine,
RiFilePdfLine,
RiFileTextLine,
} from "@remixicon/react";
import { toast } from "sonner";
import { useState } from "react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import { useState } from "react";
import { toast } from "sonner";
import * as XLSX from "xlsx";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
import type { LancamentoItem } from "./types";
interface LancamentosExportProps {
lancamentos: LancamentoItem[];
period: string;
lancamentos: LancamentoItem[];
period: string;
}
export function LancamentosExport({
lancamentos,
period,
lancamentos,
period,
}: LancamentosExportProps) {
const [isExporting, setIsExporting] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const getFileName = (extension: string) => {
return `lancamentos-${period}.${extension}`;
};
const getFileName = (extension: string) => {
return `lancamentos-${period}.${extension}`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const getContaCartaoName = (lancamento: LancamentoItem) => {
if (lancamento.contaName) return lancamento.contaName;
if (lancamento.cartaoName) return lancamento.cartaoName;
return "-";
};
const getContaCartaoName = (lancamento: LancamentoItem) => {
if (lancamento.contaName) return lancamento.contaName;
if (lancamento.cartaoName) return lancamento.cartaoName;
return "-";
};
const exportToCSV = () => {
try {
setIsExporting(true);
const exportToCSV = () => {
try {
setIsExporting(true);
const headers = [
"Data",
"Nome",
"Tipo",
"Condição",
"Pagamento",
"Valor",
"Categoria",
"Conta/Cartão",
"Pagador",
];
const rows: string[][] = [];
const headers = [
"Data",
"Nome",
"Tipo",
"Condição",
"Pagamento",
"Valor",
"Categoria",
"Conta/Cartão",
"Pagador",
];
const rows: string[][] = [];
lancamentos.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
lancamento.name,
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
formatCurrency(lancamento.amount),
lancamento.categoriaName ?? "-",
getContaCartaoName(lancamento),
lancamento.pagadorName ?? "-",
];
rows.push(row);
});
lancamentos.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
lancamento.name,
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
formatCurrency(lancamento.amount),
lancamento.categoriaName ?? "-",
getContaCartaoName(lancamento),
lancamento.pagadorName ?? "-",
];
rows.push(row);
});
const csvContent = [
headers.join(","),
...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")),
].join("\n");
const csvContent = [
headers.join(","),
...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")),
].join("\n");
const blob = new Blob(["\uFEFF" + csvContent], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = getFileName("csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
const blob = new Blob([`\uFEFF${csvContent}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = getFileName("csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Lançamentos exportados em CSV com sucesso!");
} catch (error) {
console.error("Error exporting to CSV:", error);
toast.error("Erro ao exportar lançamentos em CSV");
} finally {
setIsExporting(false);
}
};
toast.success("Lançamentos exportados em CSV com sucesso!");
} catch (error) {
console.error("Error exporting to CSV:", error);
toast.error("Erro ao exportar lançamentos em CSV");
} finally {
setIsExporting(false);
}
};
const exportToExcel = () => {
try {
setIsExporting(true);
const exportToExcel = () => {
try {
setIsExporting(true);
const headers = [
"Data",
"Nome",
"Tipo",
"Condição",
"Pagamento",
"Valor",
"Categoria",
"Conta/Cartão",
"Pagador",
];
const rows: (string | number)[][] = [];
const headers = [
"Data",
"Nome",
"Tipo",
"Condição",
"Pagamento",
"Valor",
"Categoria",
"Conta/Cartão",
"Pagador",
];
const rows: (string | number)[][] = [];
lancamentos.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
lancamento.name,
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
lancamento.amount,
lancamento.categoriaName ?? "-",
getContaCartaoName(lancamento),
lancamento.pagadorName ?? "-",
];
rows.push(row);
});
lancamentos.forEach((lancamento) => {
const row = [
formatDate(lancamento.purchaseDate),
lancamento.name,
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
lancamento.amount,
lancamento.categoriaName ?? "-",
getContaCartaoName(lancamento),
lancamento.pagadorName ?? "-",
];
rows.push(row);
});
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
ws["!cols"] = [
{ wch: 12 }, // Data
{ wch: 30 }, // Nome
{ wch: 15 }, // Tipo
{ wch: 15 }, // Condição
{ wch: 20 }, // Pagamento
{ wch: 15 }, // Valor
{ wch: 20 }, // Categoria
{ wch: 20 }, // Conta/Cartão
{ wch: 20 }, // Pagador
];
ws["!cols"] = [
{ wch: 12 }, // Data
{ wch: 30 }, // Nome
{ wch: 15 }, // Tipo
{ wch: 15 }, // Condição
{ wch: 20 }, // Pagamento
{ wch: 15 }, // Valor
{ wch: 20 }, // Categoria
{ wch: 20 }, // Conta/Cartão
{ wch: 20 }, // Pagador
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
XLSX.writeFile(wb, getFileName("xlsx"));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
XLSX.writeFile(wb, getFileName("xlsx"));
toast.success("Lançamentos exportados em Excel com sucesso!");
} catch (error) {
console.error("Error exporting to Excel:", error);
toast.error("Erro ao exportar lançamentos em Excel");
} finally {
setIsExporting(false);
}
};
toast.success("Lançamentos exportados em Excel com sucesso!");
} catch (error) {
console.error("Error exporting to Excel:", error);
toast.error("Erro ao exportar lançamentos em Excel");
} finally {
setIsExporting(false);
}
};
const exportToPDF = () => {
try {
setIsExporting(true);
const exportToPDF = () => {
try {
setIsExporting(true);
const doc = new jsPDF({ orientation: "landscape" });
const doc = new jsPDF({ orientation: "landscape" });
doc.setFontSize(16);
doc.text("Lançamentos", 14, 15);
doc.setFontSize(16);
doc.text("Lançamentos", 14, 15);
doc.setFontSize(10);
const periodParts = period.split("-");
const monthNames = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const formattedPeriod =
periodParts.length === 2
? `${monthNames[Number.parseInt(periodParts[1]) - 1]}/${periodParts[0]}`
: period;
doc.text(`Período: ${formattedPeriod}`, 14, 22);
doc.text(`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, 14, 27);
doc.setFontSize(10);
const periodParts = period.split("-");
const monthNames = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const formattedPeriod =
periodParts.length === 2
? `${monthNames[Number.parseInt(periodParts[1], 10) - 1]}/${periodParts[0]}`
: period;
doc.text(`Período: ${formattedPeriod}`, 14, 22);
doc.text(`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, 14, 27);
const headers = [
[
"Data",
"Nome",
"Tipo",
"Condição",
"Pagamento",
"Valor",
"Categoria",
"Conta/Cartão",
"Pagador",
],
];
const headers = [
[
"Data",
"Nome",
"Tipo",
"Condição",
"Pagamento",
"Valor",
"Categoria",
"Conta/Cartão",
"Pagador",
],
];
const body = lancamentos.map((lancamento) => [
formatDate(lancamento.purchaseDate),
lancamento.name,
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
formatCurrency(lancamento.amount),
lancamento.categoriaName ?? "-",
getContaCartaoName(lancamento),
lancamento.pagadorName ?? "-",
]);
const body = lancamentos.map((lancamento) => [
formatDate(lancamento.purchaseDate),
lancamento.name,
lancamento.transactionType,
lancamento.condition,
lancamento.paymentMethod,
formatCurrency(lancamento.amount),
lancamento.categoriaName ?? "-",
getContaCartaoName(lancamento),
lancamento.pagadorName ?? "-",
]);
autoTable(doc, {
head: headers,
body: body,
startY: 32,
styles: {
fontSize: 8,
cellPadding: 2,
},
headStyles: {
fillColor: [59, 130, 246],
textColor: 255,
fontStyle: "bold",
},
columnStyles: {
0: { cellWidth: 20 }, // Data
1: { cellWidth: 40 }, // Nome
2: { cellWidth: 25 }, // Tipo
3: { cellWidth: 25 }, // Condição
4: { cellWidth: 30 }, // Pagamento
5: { cellWidth: 25 }, // Valor
6: { cellWidth: 30 }, // Categoria
7: { cellWidth: 30 }, // Conta/Cartão
8: { cellWidth: 30 }, // Pagador
},
didParseCell: (cellData) => {
if (cellData.section === "body" && cellData.column.index === 5) {
const lancamento = lancamentos[cellData.row.index];
if (lancamento) {
if (lancamento.transactionType === "Despesa") {
cellData.cell.styles.textColor = [220, 38, 38];
} else if (lancamento.transactionType === "Receita") {
cellData.cell.styles.textColor = [22, 163, 74];
}
}
}
},
margin: { top: 32 },
});
autoTable(doc, {
head: headers,
body: body,
startY: 32,
styles: {
fontSize: 8,
cellPadding: 2,
},
headStyles: {
fillColor: [59, 130, 246],
textColor: 255,
fontStyle: "bold",
},
columnStyles: {
0: { cellWidth: 20 }, // Data
1: { cellWidth: 40 }, // Nome
2: { cellWidth: 25 }, // Tipo
3: { cellWidth: 25 }, // Condição
4: { cellWidth: 30 }, // Pagamento
5: { cellWidth: 25 }, // Valor
6: { cellWidth: 30 }, // Categoria
7: { cellWidth: 30 }, // Conta/Cartão
8: { cellWidth: 30 }, // Pagador
},
didParseCell: (cellData) => {
if (cellData.section === "body" && cellData.column.index === 5) {
const lancamento = lancamentos[cellData.row.index];
if (lancamento) {
if (lancamento.transactionType === "Despesa") {
cellData.cell.styles.textColor = [220, 38, 38];
} else if (lancamento.transactionType === "Receita") {
cellData.cell.styles.textColor = [22, 163, 74];
}
}
}
},
margin: { top: 32 },
});
doc.save(getFileName("pdf"));
doc.save(getFileName("pdf"));
toast.success("Lançamentos exportados em PDF com sucesso!");
} catch (error) {
console.error("Error exporting to PDF:", error);
toast.error("Erro ao exportar lançamentos em PDF");
} finally {
setIsExporting(false);
}
};
toast.success("Lançamentos exportados em PDF com sucesso!");
} catch (error) {
console.error("Error exporting to PDF:", error);
toast.error("Erro ao exportar lançamentos em PDF");
} finally {
setIsExporting(false);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="text-sm border-dashed"
disabled={isExporting || lancamentos.length === 0}
aria-label="Exportar lançamentos"
>
<RiDownloadLine className="size-4" />
{isExporting ? "Exportando..." : "Exportar"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportToCSV} disabled={isExporting}>
<RiFileTextLine className="mr-2 h-4 w-4" />
Exportar como CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToExcel} disabled={isExporting}>
<RiFileExcelLine className="mr-2 h-4 w-4" />
Exportar como Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToPDF} disabled={isExporting}>
<RiFilePdfLine className="mr-2 h-4 w-4" />
Exportar como PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="text-sm border-dashed"
disabled={isExporting || lancamentos.length === 0}
aria-label="Exportar lançamentos"
>
<RiDownloadLine className="size-4" />
{isExporting ? "Exportando..." : "Exportar"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportToCSV} disabled={isExporting}>
<RiFileTextLine className="mr-2 h-4 w-4" />
Exportar como CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToExcel} disabled={isExporting}>
<RiFileExcelLine className="mr-2 h-4 w-4" />
Exportar como Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToPDF} disabled={isExporting}>
<RiFilePdfLine className="mr-2 h-4 w-4" />
Exportar como PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +1,120 @@
"use client";
import { RiBankCard2Line, RiBankLine } from "@remixicon/react";
import Image from "next/image";
import { CategoryIcon } from "@/components/categorias/category-icon";
import DotIcon from "@/components/dot-icon";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
import { RiBankCard2Line, RiBankLine } from "@remixicon/react";
import Image from "next/image";
type SelectItemContentProps = {
label: string;
avatarUrl?: string | null;
logo?: string | null;
icon?: string | null;
label: string;
avatarUrl?: string | null;
logo?: string | null;
icon?: string | null;
};
export function PagadorSelectContent({
label,
avatarUrl,
label,
avatarUrl,
}: SelectItemContentProps) {
const avatarSrc = getAvatarSrc(avatarUrl);
const initial = label.charAt(0).toUpperCase() || "?";
const avatarSrc = getAvatarSrc(avatarUrl);
const initial = label.charAt(0).toUpperCase() || "?";
return (
<span className="flex items-center gap-2">
<Avatar className="size-5 border border-border/60 bg-background">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-[10px] font-medium uppercase">
{initial}
</AvatarFallback>
</Avatar>
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
<Avatar className="size-5 border border-border/60 bg-background">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-[10px] font-medium uppercase">
{initial}
</AvatarFallback>
</Avatar>
<span>{label}</span>
</span>
);
}
export function CategoriaSelectContent({
label,
icon,
label,
icon,
}: SelectItemContentProps) {
return (
<span className="flex items-center gap-2">
<CategoryIcon name={icon} className="size-4" />
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
<CategoryIcon name={icon} className="size-4" />
<span>{label}</span>
</span>
);
}
export function TransactionTypeSelectContent({ label }: { label: string }) {
const colorMap: Record<string, string> = {
Receita: "bg-emerald-600 dark:bg-emerald-400",
Despesa: "bg-red-600 dark:bg-red-400",
Transferência: "bg-blue-600 dark:bg-blue-400",
};
const colorMap: Record<string, string> = {
Receita: "bg-emerald-600 dark:bg-emerald-400",
Despesa: "bg-red-600 dark:bg-red-400",
Transferência: "bg-blue-600 dark:bg-blue-400",
};
return (
<span className="flex items-center gap-2">
<DotIcon bg_dot={colorMap[label]} />
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
<DotIcon bg_dot={colorMap[label]} />
<span>{label}</span>
</span>
);
}
export function PaymentMethodSelectContent({ label }: { label: string }) {
const icon = getPaymentMethodIcon(label);
const icon = getPaymentMethodIcon(label);
return (
<span className="flex items-center gap-2">
{icon}
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
{icon}
<span>{label}</span>
</span>
);
}
export function ConditionSelectContent({ label }: { label: string }) {
const icon = getConditionIcon(label);
const icon = getConditionIcon(label);
return (
<span className="flex items-center gap-2">
{icon}
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
{icon}
<span>{label}</span>
</span>
);
}
export function ContaCartaoSelectContent({
label,
logo,
isCartao,
label,
logo,
isCartao,
}: SelectItemContentProps & { isCartao?: boolean }) {
const resolveLogoSrc = (logoPath: string | null) => {
if (!logoPath) {
return null;
}
const resolveLogoSrc = (logoPath: string | null) => {
if (!logoPath) {
return null;
}
const fileName = logoPath.split("/").filter(Boolean).pop() ?? logoPath;
return `/logos/${fileName}`;
};
const fileName = logoPath.split("/").filter(Boolean).pop() ?? logoPath;
return `/logos/${fileName}`;
};
const logoSrc = resolveLogoSrc(logo);
const Icon = isCartao ? RiBankCard2Line : RiBankLine;
const logoSrc = resolveLogoSrc(logo);
const Icon = isCartao ? RiBankCard2Line : RiBankLine;
return (
<span className="flex items-center gap-2">
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={20}
height={20}
className="rounded"
/>
) : (
<Icon className="size-4 text-muted-foreground" aria-hidden />
)}
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={20}
height={20}
className="rounded"
/>
) : (
<Icon className="size-4 text-muted-foreground" aria-hidden />
)}
<span>{label}</span>
</span>
);
}

View File

@@ -1,188 +1,198 @@
"use client";
import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { displayPeriod } from "@/lib/utils/period";
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useTransition } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import MoneyValues from "@/components/money-values";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { displayPeriod } from "@/lib/utils/period";
interface AnticipationCardProps {
anticipation: InstallmentAnticipationWithRelations;
onViewLancamento?: (lancamentoId: string) => void;
onCanceled?: () => void;
anticipation: InstallmentAnticipationWithRelations;
onViewLancamento?: (lancamentoId: string) => void;
onCanceled?: () => void;
}
export function AnticipationCard({
anticipation,
onViewLancamento,
onCanceled,
anticipation,
onViewLancamento,
onCanceled,
}: AnticipationCardProps) {
const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
const isSettled = anticipation.lancamento.isSettled === true;
const canCancel = !isSettled;
const isSettled = anticipation.lancamento.isSettled === true;
const canCancel = !isSettled;
const formatDate = (date: Date) => {
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
};
const formatDate = (date: Date) => {
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
};
const handleCancel = async () => {
startTransition(async () => {
const result = await cancelInstallmentAnticipationAction({
anticipationId: anticipation.id,
});
const handleCancel = async () => {
startTransition(async () => {
const result = await cancelInstallmentAnticipationAction({
anticipationId: anticipation.id,
});
if (result.success) {
toast.success(result.message);
onCanceled?.();
} else {
toast.error(result.error || "Erro ao cancelar antecipação");
}
});
};
if (result.success) {
toast.success(result.message);
onCanceled?.();
} else {
toast.error(result.error || "Erro ao cancelar antecipação");
}
});
};
const handleViewLancamento = () => {
onViewLancamento?.(anticipation.lancamentoId);
};
const handleViewLancamento = () => {
onViewLancamento?.(anticipation.lancamentoId);
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-base">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<CardDescription>
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
{formatDate(anticipation.anticipationDate)}
</CardDescription>
</div>
<Badge variant="secondary">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</CardHeader>
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-base">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<CardDescription>
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
{formatDate(anticipation.anticipationDate)}
</CardDescription>
</div>
<Badge variant="secondary">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">Valor Original</dt>
<dd className="mt-1 font-medium tabular-nums">
<MoneyValues amount={Number(anticipation.totalAmount)} />
</dd>
</div>
<CardContent className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">Valor Original</dt>
<dd className="mt-1 font-medium tabular-nums">
<MoneyValues amount={Number(anticipation.totalAmount)} />
</dd>
</div>
{Number(anticipation.discount) > 0 && (
<div>
<dt className="text-muted-foreground">Desconto</dt>
<dd className="mt-1 font-medium tabular-nums text-green-600">
- <MoneyValues amount={Number(anticipation.discount)} />
</dd>
</div>
)}
{Number(anticipation.discount) > 0 && (
<div>
<dt className="text-muted-foreground">Desconto</dt>
<dd className="mt-1 font-medium tabular-nums text-green-600">
- <MoneyValues amount={Number(anticipation.discount)} />
</dd>
</div>
)}
<div className={Number(anticipation.discount) > 0 ? "col-span-2 border-t pt-3" : ""}>
<dt className="text-muted-foreground">
{Number(anticipation.discount) > 0 ? "Valor Final" : "Valor Total"}
</dt>
<dd className="mt-1 text-lg font-semibold tabular-nums text-primary">
<MoneyValues
amount={
Number(anticipation.totalAmount) < 0
? Number(anticipation.totalAmount) + Number(anticipation.discount)
: Number(anticipation.totalAmount) - Number(anticipation.discount)
}
/>
</dd>
</div>
<div
className={
Number(anticipation.discount) > 0
? "col-span-2 border-t pt-3"
: ""
}
>
<dt className="text-muted-foreground">
{Number(anticipation.discount) > 0
? "Valor Final"
: "Valor Total"}
</dt>
<dd className="mt-1 text-lg font-semibold tabular-nums text-primary">
<MoneyValues
amount={
Number(anticipation.totalAmount) < 0
? Number(anticipation.totalAmount) +
Number(anticipation.discount)
: Number(anticipation.totalAmount) -
Number(anticipation.discount)
}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Status do Lançamento</dt>
<dd className="mt-1">
<Badge variant={isSettled ? "success" : "outline"}>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Status do Lançamento</dt>
<dd className="mt-1">
<Badge variant={isSettled ? "success" : "outline"}>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</dd>
</div>
{anticipation.pagador && (
<div>
<dt className="text-muted-foreground">Pagador</dt>
<dd className="mt-1 font-medium">{anticipation.pagador.name}</dd>
</div>
)}
{anticipation.pagador && (
<div>
<dt className="text-muted-foreground">Pagador</dt>
<dd className="mt-1 font-medium">{anticipation.pagador.name}</dd>
</div>
)}
{anticipation.categoria && (
<div>
<dt className="text-muted-foreground">Categoria</dt>
<dd className="mt-1 font-medium">
{anticipation.categoria.name}
</dd>
</div>
)}
</dl>
{anticipation.categoria && (
<div>
<dt className="text-muted-foreground">Categoria</dt>
<dd className="mt-1 font-medium">
{anticipation.categoria.name}
</dd>
</div>
)}
</dl>
{anticipation.note && (
<div className="rounded-lg border bg-muted/20 p-3">
<dt className="text-xs font-medium text-muted-foreground">
Observação
</dt>
<dd className="mt-1 text-sm">{anticipation.note}</dd>
</div>
)}
</CardContent>
{anticipation.note && (
<div className="rounded-lg border bg-muted/20 p-3">
<dt className="text-xs font-medium text-muted-foreground">
Observação
</dt>
<dd className="mt-1 text-sm">{anticipation.note}</dd>
</div>
)}
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={handleViewLancamento}
disabled={isPending}
>
<RiEyeLine className="mr-2 size-4" />
Ver Lançamento
</Button>
<CardFooter className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={handleViewLancamento}
disabled={isPending}
>
<RiEyeLine className="mr-2 size-4" />
Ver Lançamento
</Button>
{canCancel && (
<ConfirmActionDialog
trigger={
<Button variant="destructive" size="sm" disabled={isPending}>
<RiCloseLine className="mr-2 size-4" />
Cancelar Antecipação
</Button>
}
title="Cancelar antecipação?"
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
confirmLabel="Cancelar Antecipação"
confirmVariant="destructive"
pendingLabel="Cancelando..."
onConfirm={handleCancel}
/>
)}
{canCancel && (
<ConfirmActionDialog
trigger={
<Button variant="destructive" size="sm" disabled={isPending}>
<RiCloseLine className="mr-2 size-4" />
Cancelar Antecipação
</Button>
}
title="Cancelar antecipação?"
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
confirmLabel="Cancelar Antecipação"
confirmVariant="destructive"
pendingLabel="Cancelando..."
onConfirm={handleCancel}
/>
)}
{isSettled && (
<div className="text-xs text-muted-foreground">
Não é possível cancelar uma antecipação paga
</div>
)}
</CardFooter>
</Card>
);
{isSettled && (
<div className="text-xs text-muted-foreground">
Não é possível cancelar uma antecipação paga
</div>
)}
</CardFooter>
</Card>
);
}

View File

@@ -4,126 +4,126 @@ import { RiCheckLine, RiSearchLine } from "@remixicon/react";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils/ui";
export interface EstabelecimentoInputProps {
id?: string;
value: string;
onChange: (value: string) => void;
estabelecimentos: string[];
placeholder?: string;
required?: boolean;
maxLength?: number;
id?: string;
value: string;
onChange: (value: string) => void;
estabelecimentos: string[];
placeholder?: string;
required?: boolean;
maxLength?: number;
}
export function EstabelecimentoInput({
id,
value,
onChange,
estabelecimentos = [],
placeholder = "Ex.: Padaria",
required = false,
maxLength = 20,
id,
value,
onChange,
estabelecimentos = [],
placeholder = "Ex.: Padaria",
required = false,
maxLength = 20,
}: EstabelecimentoInputProps) {
const [open, setOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState("");
const [open, setOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState("");
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setOpen(false);
setSearchValue("");
};
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setOpen(false);
setSearchValue("");
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
setSearchValue(newValue);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
setSearchValue(newValue);
// Open popover when user types and there are suggestions
if (newValue.length > 0 && estabelecimentos.length > 0) {
setOpen(true);
}
};
// Open popover when user types and there are suggestions
if (newValue.length > 0 && estabelecimentos.length > 0) {
setOpen(true);
}
};
const filteredEstabelecimentos = React.useMemo(() => {
if (!searchValue) return estabelecimentos;
const filteredEstabelecimentos = React.useMemo(() => {
if (!searchValue) return estabelecimentos;
const lowerSearch = searchValue.toLowerCase();
return estabelecimentos.filter((item) =>
item.toLowerCase().includes(lowerSearch)
);
}, [estabelecimentos, searchValue]);
const lowerSearch = searchValue.toLowerCase();
return estabelecimentos.filter((item) =>
item.toLowerCase().includes(lowerSearch),
);
}, [estabelecimentos, searchValue]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative">
<Input
id={id}
value={value}
onChange={handleInputChange}
placeholder={placeholder}
required={required}
maxLength={maxLength}
autoComplete="off"
onFocus={() => {
if (estabelecimentos.length > 0) {
setOpen(true);
}
}}
/>
{estabelecimentos.length > 0 && (
<RiSearchLine className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
)}
</div>
</PopoverTrigger>
{estabelecimentos.length > 0 && (
<PopoverContent
className="p-0 w-[--radix-popover-trigger-width]"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty className="p-6">
Nenhum estabelecimento encontrado.
</CommandEmpty>
<CommandGroup className="p-1">
{filteredEstabelecimentos.map((item) => (
<CommandItem
key={item}
value={item}
onSelect={() => handleSelect(item)}
className="cursor-pointer gap-1"
>
<RiCheckLine
className={cn(
"size-4 shrink-0",
value === item
? "opacity-100 text-green-500"
: "opacity-5"
)}
/>
<span className="truncate flex-1">{item}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative">
<Input
id={id}
value={value}
onChange={handleInputChange}
placeholder={placeholder}
required={required}
maxLength={maxLength}
autoComplete="off"
onFocus={() => {
if (estabelecimentos.length > 0) {
setOpen(true);
}
}}
/>
{estabelecimentos.length > 0 && (
<RiSearchLine className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
)}
</div>
</PopoverTrigger>
{estabelecimentos.length > 0 && (
<PopoverContent
className="p-0 w-[--radix-popover-trigger-width]"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Command>
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty className="p-6">
Nenhum estabelecimento encontrado.
</CommandEmpty>
<CommandGroup className="p-1">
{filteredEstabelecimentos.map((item) => (
<CommandItem
key={item}
value={item}
onSelect={() => handleSelect(item)}
className="cursor-pointer gap-1"
>
<RiCheckLine
className={cn(
"size-4 shrink-0",
value === item
? "opacity-100 text-green-500"
: "opacity-5",
)}
/>
<span className="truncate flex-1">{item}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
)}
</Popover>
);
}

View File

@@ -3,70 +3,70 @@
import { cn } from "@/lib/utils/ui";
interface EstabelecimentoLogoProps {
name: string;
size?: number;
className?: string;
name: string;
size?: number;
className?: string;
}
const COLOR_PALETTE = [
"bg-purple-400 dark:bg-purple-600",
"bg-pink-400 dark:bg-pink-600",
"bg-red-400 dark:bg-red-600",
"bg-orange-400 dark:bg-orange-600",
"bg-indigo-400 dark:bg-indigo-600",
"bg-violet-400 dark:bg-violet-600",
"bg-fuchsia-400 dark:bg-fuchsia-600",
"bg-rose-400 dark:bg-rose-600",
"bg-amber-400 dark:bg-amber-600",
"bg-emerald-400 dark:bg-emerald-600",
"bg-purple-400 dark:bg-purple-600",
"bg-pink-400 dark:bg-pink-600",
"bg-red-400 dark:bg-red-600",
"bg-orange-400 dark:bg-orange-600",
"bg-indigo-400 dark:bg-indigo-600",
"bg-violet-400 dark:bg-violet-600",
"bg-fuchsia-400 dark:bg-fuchsia-600",
"bg-rose-400 dark:bg-rose-600",
"bg-amber-400 dark:bg-amber-600",
"bg-emerald-400 dark:bg-emerald-600",
];
function getInitials(name: string): string {
if (!name || !name.trim()) return "?";
if (!name || !name.trim()) return "?";
const words = name.trim().split(/\s+/);
const words = name.trim().split(/\s+/);
if (words.length === 1) {
return words[0]?.[0]?.toUpperCase() || "?";
}
if (words.length === 1) {
return words[0]?.[0]?.toUpperCase() || "?";
}
const firstInitial = words[0]?.[0]?.toUpperCase() || "";
const secondInitial = words[1]?.[0]?.toUpperCase() || "";
const firstInitial = words[0]?.[0]?.toUpperCase() || "";
const secondInitial = words[1]?.[0]?.toUpperCase() || "";
return `${firstInitial}${secondInitial}`;
return `${firstInitial}${secondInitial}`;
}
function generateColorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % COLOR_PALETTE.length;
return COLOR_PALETTE[index] || "bg-gray-400";
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % COLOR_PALETTE.length;
return COLOR_PALETTE[index] || "bg-gray-400";
}
export function EstabelecimentoLogo({
name,
size = 32,
className,
name,
size = 32,
className,
}: EstabelecimentoLogoProps) {
const initials = getInitials(name);
const colorClass = generateColorFromName(name);
const initials = getInitials(name);
const colorClass = generateColorFromName(name);
return (
<div
className={cn(
"flex items-center justify-center rounded-md text-white font-medium shrink-0 ",
colorClass,
className
)}
style={{
width: size,
height: size,
fontSize: size * 0.4,
}}
>
{initials}
</div>
);
return (
<div
className={cn(
"flex items-center justify-center rounded-md text-white font-medium shrink-0 ",
colorClass,
className,
)}
style={{
width: size,
height: size,
fontSize: size * 0.4,
}}
>
{initials}
</div>
);
}

View File

@@ -1,4 +1,4 @@
export { AnticipationCard } from "./anticipation-card";
export { EstabelecimentoInput } from "./estabelecimento-input";
export { InstallmentTimeline } from "./installment-timeline";
export { EstabelecimentoLogo } from "./estabelecimento-logo";
export { InstallmentTimeline } from "./installment-timeline";

View File

@@ -1,92 +1,92 @@
import {
calculateLastInstallmentDate,
formatCurrentInstallment,
formatLastInstallmentDate,
formatPurchaseDate,
} from "@/lib/installments/utils";
import { RiArrowDownFill, RiCheckLine } from "@remixicon/react";
import {
calculateLastInstallmentDate,
formatCurrentInstallment,
formatLastInstallmentDate,
formatPurchaseDate,
} from "@/lib/installments/utils";
type InstallmentTimelineProps = {
purchaseDate: Date;
currentInstallment: number;
totalInstallments: number;
period: string;
purchaseDate: Date;
currentInstallment: number;
totalInstallments: number;
period: string;
};
export function InstallmentTimeline({
purchaseDate,
currentInstallment,
totalInstallments,
period,
purchaseDate,
currentInstallment,
totalInstallments,
period,
}: InstallmentTimelineProps) {
const lastInstallmentDate = calculateLastInstallmentDate(
period,
currentInstallment,
totalInstallments,
);
const lastInstallmentDate = calculateLastInstallmentDate(
period,
currentInstallment,
totalInstallments,
);
return (
<div className="relative flex items-center justify-between px-4 py-4">
{/* Linha de conexão */}
<div className="absolute left-0 right-0 top-6 h-0.5 bg-border">
<div
className="h-full bg-green-600 transition-all duration-300"
style={{
width: `${
((currentInstallment - 1) / (totalInstallments - 1)) * 100
}%`,
}}
/>
</div>
return (
<div className="relative flex items-center justify-between px-4 py-4">
{/* Linha de conexão */}
<div className="absolute left-0 right-0 top-6 h-0.5 bg-border">
<div
className="h-full bg-green-600 transition-all duration-300"
style={{
width: `${
((currentInstallment - 1) / (totalInstallments - 1)) * 100
}%`,
}}
/>
</div>
{/* Ponto 1: Data de Compra */}
<div className="relative z-10 flex flex-col items-center gap-2">
<div className="flex size-4 items-center justify-center rounded-full border-2 border-green-600 bg-green-600 shadow-sm">
<RiCheckLine className="size-5 text-white" />
</div>
<div className="flex flex-col items-center">
<span className="text-xs font-medium text-foreground">
Data de Compra
</span>
<span className="text-xs text-muted-foreground">
{formatPurchaseDate(purchaseDate)}
</span>
</div>
</div>
{/* Ponto 1: Data de Compra */}
<div className="relative z-10 flex flex-col items-center gap-2">
<div className="flex size-4 items-center justify-center rounded-full border-2 border-green-600 bg-green-600 shadow-sm">
<RiCheckLine className="size-5 text-white" />
</div>
<div className="flex flex-col items-center">
<span className="text-xs font-medium text-foreground">
Data de Compra
</span>
<span className="text-xs text-muted-foreground">
{formatPurchaseDate(purchaseDate)}
</span>
</div>
</div>
{/* Ponto 2: Parcela Atual */}
<div className="relative z-10 flex flex-col items-center gap-2">
<div
className={`flex size-4 items-center justify-center rounded-full border-2 shadow-sm border-orange-600 bg-orange-600`}
>
<RiArrowDownFill className="size-5 text-white" />
</div>
<div className="flex flex-col items-center">
<span className="text-xs font-medium text-foreground">
Parcela Atual
</span>
<span className="text-xs text-muted-foreground">
{formatCurrentInstallment(currentInstallment, totalInstallments)}
</span>
</div>
</div>
{/* Ponto 2: Parcela Atual */}
<div className="relative z-10 flex flex-col items-center gap-2">
<div
className={`flex size-4 items-center justify-center rounded-full border-2 shadow-sm border-orange-600 bg-orange-600`}
>
<RiArrowDownFill className="size-5 text-white" />
</div>
<div className="flex flex-col items-center">
<span className="text-xs font-medium text-foreground">
Parcela Atual
</span>
<span className="text-xs text-muted-foreground">
{formatCurrentInstallment(currentInstallment, totalInstallments)}
</span>
</div>
</div>
{/* Ponto 3: Última Parcela */}
<div className="relative z-10 flex flex-col items-center gap-2">
<div
className={`flex size-4 items-center justify-center rounded-full border-2 shadow-sm border-green-600 bg-green-600`}
>
<RiCheckLine className="size-5 text-white" />
</div>
<div className="flex flex-col items-center">
<span className="text-xs font-medium text-foreground">
Última Parcela
</span>
<span className="text-xs text-muted-foreground">
{formatLastInstallmentDate(lastInstallmentDate)}
</span>
</div>
</div>
</div>
);
{/* Ponto 3: Última Parcela */}
<div className="relative z-10 flex flex-col items-center gap-2">
<div
className={`flex size-4 items-center justify-center rounded-full border-2 shadow-sm border-green-600 bg-green-600`}
>
<RiCheckLine className="size-5 text-white" />
</div>
<div className="flex flex-col items-center">
<span className="text-xs font-medium text-foreground">
Última Parcela
</span>
<span className="text-xs text-muted-foreground">
{formatLastInstallmentDate(lastInstallmentDate)}
</span>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +1,61 @@
export type LancamentoItem = {
id: string;
userId: string;
name: string;
purchaseDate: string;
period: string;
transactionType: string;
amount: number;
condition: string;
paymentMethod: string;
pagadorId: string | null;
pagadorName: string | null;
pagadorAvatar: string | null;
pagadorRole: string | null;
contaId: string | null;
contaName: string | null;
contaLogo: string | null;
cartaoId: string | null;
cartaoName: string | null;
cartaoLogo: string | null;
categoriaId: string | null;
categoriaName: string | null;
categoriaType: string | null;
categoriaIcon: string | null;
installmentCount: number | null;
recurrenceCount: number | null;
currentInstallment: number | null;
dueDate: string | null;
boletoPaymentDate: string | null;
note: string | null;
isSettled: boolean | null;
isDivided: boolean;
isAnticipated: boolean;
anticipationId: string | null;
seriesId: string | null;
readonly?: boolean;
id: string;
userId: string;
name: string;
purchaseDate: string;
period: string;
transactionType: string;
amount: number;
condition: string;
paymentMethod: string;
pagadorId: string | null;
pagadorName: string | null;
pagadorAvatar: string | null;
pagadorRole: string | null;
contaId: string | null;
contaName: string | null;
contaLogo: string | null;
cartaoId: string | null;
cartaoName: string | null;
cartaoLogo: string | null;
categoriaId: string | null;
categoriaName: string | null;
categoriaType: string | null;
categoriaIcon: string | null;
installmentCount: number | null;
recurrenceCount: number | null;
currentInstallment: number | null;
dueDate: string | null;
boletoPaymentDate: string | null;
note: string | null;
isSettled: boolean | null;
isDivided: boolean;
isAnticipated: boolean;
anticipationId: string | null;
seriesId: string | null;
readonly?: boolean;
};
export type SelectOption = {
value: string;
label: string;
role?: string | null;
group?: string | null;
slug?: string | null;
avatarUrl?: string | null;
logo?: string | null;
icon?: string | null;
accountType?: string | null;
value: string;
label: string;
role?: string | null;
group?: string | null;
slug?: string | null;
avatarUrl?: string | null;
logo?: string | null;
icon?: string | null;
accountType?: string | null;
};
export type LancamentoFilterOption = {
slug: string;
label: string;
icon?: string | null;
avatarUrl?: string | null;
slug: string;
label: string;
icon?: string | null;
avatarUrl?: string | null;
};
export type ContaCartaoFilterOption = LancamentoFilterOption & {
kind: "conta" | "cartao";
logo?: string | null;
kind: "conta" | "cartao";
logo?: string | null;
};