feat: implementar melhorias em filtros, orçamentos e exportação

Refatoração de Filtros:
  - Move filtros select para Drawer lateral direito
  - Mantém busca fora do Drawer para acesso rápido
  - Adiciona indicador visual de filtros ativos
  - Implementa aplicação instantânea de filtros
  - Adiciona border-dashed e aumenta input de busca

  Cópia de Orçamentos:
  - Implementa funcionalidade de copiar orçamentos do mês anterior
  - Adiciona server action com validações e tratamento de erros
  - Cria modal de confirmação para a ação
  - Evita duplicações automáticas

  Exportação de Lançamentos:
  - Adiciona exportação em CSV, XLSX e PDF
  - Integra botão de exportação nos filtros
  - Segue padrão de Relatórios de Categorias
  - Inclui formatação específica por formato
This commit is contained in:
Felipe Coutinho
2026-01-05 15:49:16 +00:00
parent 901e423959
commit 147857c5bd
6 changed files with 731 additions and 205 deletions

View File

@@ -188,3 +188,89 @@ export async function deleteBudgetAction(
return handleActionError(error); return handleActionError(error);
} }
} }
const duplicatePreviousMonthSchema = z.object({
period: periodSchema,
});
type DuplicatePreviousMonthInput = z.infer<
typeof duplicatePreviousMonthSchema
>;
export async function duplicatePreviousMonthBudgetsAction(
input: DuplicatePreviousMonthInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = duplicatePreviousMonthSchema.parse(input);
// Calcular mês anterior
const [year, month] = data.period.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
const previousDate = new Date(currentDate);
previousDate.setMonth(previousDate.getMonth() - 1);
const prevYear = previousDate.getFullYear();
const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0");
const previousPeriod = `${prevYear}-${prevMonth}`;
// Buscar orçamentos do mês anterior
const previousBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, previousPeriod)
),
});
if (previousBudgets.length === 0) {
return {
success: false,
error: "Não foram encontrados orçamentos no mês anterior.",
};
}
// Buscar orçamentos existentes do mês atual
const currentBudgets = await db.query.orcamentos.findMany({
where: and(
eq(orcamentos.userId, user.id),
eq(orcamentos.period, data.period)
),
});
// Filtrar para evitar duplicatas
const existingCategoryIds = new Set(
currentBudgets.map((b) => b.categoriaId)
);
const budgetsToCopy = previousBudgets.filter(
(b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId)
);
if (budgetsToCopy.length === 0) {
return {
success: false,
error:
"Todas as categorias do mês anterior já possuem orçamento neste mês.",
};
}
// Inserir novos orçamentos
await db.insert(orcamentos).values(
budgetsToCopy.map((b) => ({
amount: b.amount,
period: data.period,
userId: user.id,
categoriaId: b.categoriaId!,
}))
);
revalidateForEntity("orcamentos");
return {
success: true,
message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`,
};
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +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,
} from "@remixicon/react";
import { toast } from "sonner";
import { useState } from "react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import * as XLSX from "xlsx";
import type { LancamentoItem } from "./types";
interface LancamentosExportProps {
lancamentos: LancamentoItem[];
period: string;
}
export function LancamentosExport({
lancamentos,
period,
}: LancamentosExportProps) {
const [isExporting, setIsExporting] = useState(false);
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 getContaCartaoName = (lancamento: LancamentoItem) => {
if (lancamento.contaName) return lancamento.contaName;
if (lancamento.cartaoName) return lancamento.cartaoName;
return "-";
};
const exportToCSV = () => {
try {
setIsExporting(true);
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);
});
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);
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 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);
});
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
];
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);
}
};
const exportToPDF = () => {
try {
setIsExporting(true);
const doc = new jsPDF({ orientation: "landscape" });
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);
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 ?? "-",
]);
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"));
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>
);
}

View File

@@ -360,6 +360,7 @@ export function LancamentosPage({
pagadorFilterOptions={pagadorFilterOptions} pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions} categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={selectedPeriod}
onCreate={allowCreate ? handleCreate : undefined} onCreate={allowCreate ? handleCreate : undefined}
onMassAdd={allowCreate ? handleMassAdd : undefined} onMassAdd={allowCreate ? handleMassAdd : undefined}
onEdit={handleEdit} onEdit={handleEdit}

View File

@@ -9,6 +9,15 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Popover, Popover,
@@ -47,7 +56,11 @@ import {
TransactionTypeSelectContent, TransactionTypeSelectContent,
} from "../select-items"; } from "../select-items";
import { RiCheckLine, RiExpandUpDownLine } from "@remixicon/react"; import {
RiCheckLine,
RiExpandUpDownLine,
RiFilter3Line,
} from "@remixicon/react";
import type { ContaCartaoFilterOption, LancamentoFilterOption } from "../types"; import type { ContaCartaoFilterOption, LancamentoFilterOption } from "../types";
const FILTER_EMPTY_VALUE = "__all"; const FILTER_EMPTY_VALUE = "__all";
@@ -116,6 +129,7 @@ interface LancamentosFiltersProps {
categoriaOptions: LancamentoFilterOption[]; categoriaOptions: LancamentoFilterOption[];
contaCartaoOptions: ContaCartaoFilterOption[]; contaCartaoOptions: ContaCartaoFilterOption[];
className?: string; className?: string;
exportButton?: ReactNode;
} }
export function LancamentosFilters({ export function LancamentosFilters({
@@ -123,6 +137,7 @@ export function LancamentosFilters({
categoriaOptions, categoriaOptions,
contaCartaoOptions, contaCartaoOptions,
className, className,
exportButton,
}: LancamentosFiltersProps) { }: LancamentosFiltersProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -242,14 +257,58 @@ export function LancamentosFilters({
: null; : null;
const [categoriaOpen, setCategoriaOpen] = useState(false); const [categoriaOpen, setCategoriaOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const hasActiveFilters = useMemo(() => {
return (
searchParams.get("transacao") ||
searchParams.get("condicao") ||
searchParams.get("pagamento") ||
searchParams.get("pagador") ||
searchParams.get("categoria") ||
searchParams.get("contaCartao")
);
}, [searchParams]);
const handleResetFilters = useCallback(() => {
handleReset();
setDrawerOpen(false);
}, [handleReset]);
return ( return (
<div className={cn("flex flex-wrap items-center gap-2", className)}> <div className={cn("flex flex-wrap items-center gap-2", className)}>
{exportButton}
<Drawer direction="right" open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>
<Button
variant="outline"
className="text-sm border-dashed relative"
aria-label="Abrir filtros"
>
<RiFilter3Line className="size-4" />
Filtros
{hasActiveFilters && (
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
)}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Filtros</DrawerTitle>
<DrawerDescription>
Selecione os filtros desejados para refinar os lançamentos
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tipo de Lançamento</label>
<FilterSelect <FilterSelect
param="transacao" param="transacao"
placeholder="Tipo de Lançamento" placeholder="Todos"
options={buildStaticOptions(LANCAMENTO_TRANSACTION_TYPES)} options={buildStaticOptions(LANCAMENTO_TRANSACTION_TYPES)}
widthClass="w-[130px]" widthClass="w-full border-dashed"
disabled={isPending} disabled={isPending}
getParamValue={getParamValue} getParamValue={getParamValue}
onChange={handleFilterChange} onChange={handleFilterChange}
@@ -257,29 +316,42 @@ export function LancamentosFilters({
<TransactionTypeSelectContent label={label} /> <TransactionTypeSelectContent label={label} />
)} )}
/> />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Condição</label>
<FilterSelect <FilterSelect
param="condicao" param="condicao"
placeholder="Condição" placeholder="Todas"
options={buildStaticOptions(LANCAMENTO_CONDITIONS)} options={buildStaticOptions(LANCAMENTO_CONDITIONS)}
widthClass="w-[130px]" widthClass="w-full border-dashed"
disabled={isPending} disabled={isPending}
getParamValue={getParamValue} getParamValue={getParamValue}
onChange={handleFilterChange} onChange={handleFilterChange}
renderContent={(label) => <ConditionSelectContent label={label} />} renderContent={(label) => (
<ConditionSelectContent label={label} />
)}
/> />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Pagamento</label>
<FilterSelect <FilterSelect
param="pagamento" param="pagamento"
placeholder="Pagamento" placeholder="Todos"
options={buildStaticOptions(LANCAMENTO_PAYMENT_METHODS)} options={buildStaticOptions(LANCAMENTO_PAYMENT_METHODS)}
widthClass="w-[130px]" widthClass="w-full border-dashed"
disabled={isPending} disabled={isPending}
getParamValue={getParamValue} getParamValue={getParamValue}
onChange={handleFilterChange} onChange={handleFilterChange}
renderContent={(label) => <PaymentMethodSelectContent label={label} />} renderContent={(label) => (
<PaymentMethodSelectContent label={label} />
)}
/> />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Pagador</label>
<Select <Select
value={getParamValue("pagador")} value={getParamValue("pagador")}
onValueChange={(value) => onValueChange={(value) =>
@@ -291,7 +363,7 @@ export function LancamentosFilters({
disabled={isPending} disabled={isPending}
> >
<SelectTrigger <SelectTrigger
className="w-[150px] text-sm border-dashed" className="w-full text-sm border-dashed"
disabled={isPending} disabled={isPending}
> >
<span className="truncate"> <span className="truncate">
@@ -301,7 +373,7 @@ export function LancamentosFilters({
avatarUrl={selectedPagador.avatarUrl} avatarUrl={selectedPagador.avatarUrl}
/> />
) : ( ) : (
"Pagador" "Todos"
)} )}
</span> </span>
</SelectTrigger> </SelectTrigger>
@@ -317,14 +389,17 @@ export function LancamentosFilters({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Categoria</label>
<Popover open={categoriaOpen} onOpenChange={setCategoriaOpen}> <Popover open={categoriaOpen} onOpenChange={setCategoriaOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={categoriaOpen} aria-expanded={categoriaOpen}
className="w-[150px] justify-between text-sm border-dashed border-input" className="w-full justify-between text-sm border-dashed"
disabled={isPending} disabled={isPending}
> >
<span className="truncate flex items-center gap-2"> <span className="truncate flex items-center gap-2">
@@ -334,7 +409,7 @@ export function LancamentosFilters({
icon={selectedCategoria.icon} icon={selectedCategoria.icon}
/> />
) : ( ) : (
"Categoria" "Todas"
)} )}
</span> </span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" /> <RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
@@ -381,7 +456,10 @@ export function LancamentosFilters({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Conta/Cartão</label>
<Select <Select
value={getParamValue("contaCartao")} value={getParamValue("contaCartao")}
onValueChange={(value) => onValueChange={(value) =>
@@ -393,7 +471,7 @@ export function LancamentosFilters({
disabled={isPending} disabled={isPending}
> >
<SelectTrigger <SelectTrigger
className="w-[150px] text-sm border-dashed" className="w-full text-sm border-dashed"
disabled={isPending} disabled={isPending}
> >
<span className="truncate"> <span className="truncate">
@@ -404,7 +482,7 @@ export function LancamentosFilters({
isCartao={selectedContaCartao.kind === "cartao"} isCartao={selectedContaCartao.kind === "cartao"}
/> />
) : ( ) : (
"Conta/Cartão" "Todos"
)} )}
</span> </span>
</SelectTrigger> </SelectTrigger>
@@ -440,24 +518,29 @@ export function LancamentosFilters({
) : null} ) : null}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
</div>
<DrawerFooter>
<Button
type="button"
variant="outline"
onClick={handleResetFilters}
disabled={isPending || !hasActiveFilters}
>
Limpar filtros
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Input <Input
value={searchValue} value={searchValue}
onChange={(event) => setSearchValue(event.target.value)} onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar" placeholder="Buscar"
aria-label="Buscar lançamentos" aria-label="Buscar lançamentos"
className="w-[150px] text-sm border-dashed" className="w-[250px] text-sm border-dashed"
/> />
<Button
type="button"
variant="link"
size="sm"
onClick={handleReset}
disabled={isPending}
>
Limpar
</Button>
</div> </div>
); );
} }

View File

@@ -79,6 +79,7 @@ import type {
LancamentoFilterOption, LancamentoFilterOption,
LancamentoItem, LancamentoItem,
} from "../types"; } from "../types";
import { LancamentosExport } from "../lancamentos-export";
import { LancamentosFilters } from "./lancamentos-filters"; import { LancamentosFilters } from "./lancamentos-filters";
const resolveLogoSrc = (logo: string | null) => { const resolveLogoSrc = (logo: string | null) => {
@@ -626,6 +627,7 @@ type LancamentosTableProps = {
pagadorFilterOptions?: LancamentoFilterOption[]; pagadorFilterOptions?: LancamentoFilterOption[];
categoriaFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[];
contaCartaoFilterOptions?: ContaCartaoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[];
selectedPeriod?: string;
onCreate?: () => void; onCreate?: () => void;
onMassAdd?: () => void; onMassAdd?: () => void;
onEdit?: (item: LancamentoItem) => void; onEdit?: (item: LancamentoItem) => void;
@@ -649,6 +651,7 @@ export function LancamentosTable({
pagadorFilterOptions = [], pagadorFilterOptions = [],
categoriaFilterOptions = [], categoriaFilterOptions = [],
contaCartaoFilterOptions = [], contaCartaoFilterOptions = [],
selectedPeriod,
onCreate, onCreate,
onMassAdd, onMassAdd,
onEdit, onEdit,
@@ -797,6 +800,14 @@ export function LancamentosTable({
categoriaOptions={categoriaFilterOptions} categoriaOptions={categoriaFilterOptions}
contaCartaoOptions={contaCartaoFilterOptions} contaCartaoOptions={contaCartaoFilterOptions}
className="w-full lg:flex-1 lg:justify-end" className="w-full lg:flex-1 lg:justify-end"
exportButton={
selectedPeriod ? (
<LancamentosExport
lancamentos={data}
period={selectedPeriod}
/>
) : null
}
/> />
) : null} ) : null}
</div> </div>

View File

@@ -1,10 +1,13 @@
"use client"; "use client";
import { deleteBudgetAction } from "@/app/(dashboard)/orcamentos/actions"; import {
deleteBudgetAction,
duplicatePreviousMonthBudgetsAction,
} from "@/app/(dashboard)/orcamentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { EmptyState } from "@/components/empty-state"; import { EmptyState } from "@/components/empty-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { RiAddCircleLine, RiFundsLine } from "@remixicon/react"; import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card } from "../ui/card"; import { Card } from "../ui/card";
@@ -29,6 +32,7 @@ export function BudgetsPage({
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null); const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
const [removeOpen, setRemoveOpen] = useState(false); const [removeOpen, setRemoveOpen] = useState(false);
const [budgetToRemove, setBudgetToRemove] = useState<Budget | null>(null); const [budgetToRemove, setBudgetToRemove] = useState<Budget | null>(null);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const hasBudgets = budgets.length > 0; const hasBudgets = budgets.length > 0;
@@ -72,6 +76,21 @@ export function BudgetsPage({
throw new Error(result.error); throw new Error(result.error);
}, [budgetToRemove]); }, [budgetToRemove]);
const handleDuplicateConfirm = useCallback(async () => {
const result = await duplicatePreviousMonthBudgetsAction({
period: selectedPeriod,
});
if (result.success) {
toast.success(result.message);
setDuplicateOpen(false);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [selectedPeriod]);
const removeTitle = budgetToRemove const removeTitle = budgetToRemove
? `Remover orçamento de "${ ? `Remover orçamento de "${
budgetToRemove.category?.name ?? "categoria removida" budgetToRemove.category?.name ?? "categoria removida"
@@ -86,7 +105,7 @@ export function BudgetsPage({
return ( return (
<> <>
<div className="flex w-full flex-col gap-6"> <div className="flex w-full flex-col gap-6">
<div className="flex justify-start"> <div className="flex justify-start gap-4">
<BudgetDialog <BudgetDialog
mode="create" mode="create"
categories={categories} categories={categories}
@@ -98,6 +117,14 @@ export function BudgetsPage({
</Button> </Button>
} }
/> />
<Button
variant="outline"
disabled={categories.length === 0}
onClick={() => setDuplicateOpen(true)}
>
<RiFileCopyLine className="size-4" />
Copiar orçamentos do último mês
</Button>
</div> </div>
{hasBudgets ? ( {hasBudgets ? (
@@ -142,6 +169,16 @@ export function BudgetsPage({
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}
/> />
<ConfirmActionDialog
open={duplicateOpen}
onOpenChange={setDuplicateOpen}
title="Copiar orçamentos do último mês?"
description="Isso copiará os limites definidos no mês anterior para as categorias que ainda não possuem orçamento neste mês."
confirmLabel="Copiar orçamentos"
pendingLabel="Copiando..."
onConfirm={handleDuplicateConfirm}
/>
</> </>
); );
} }