diff --git a/app/(dashboard)/orcamentos/actions.ts b/app/(dashboard)/orcamentos/actions.ts index 49ec068..ebceb94 100644 --- a/app/(dashboard)/orcamentos/actions.ts +++ b/app/(dashboard)/orcamentos/actions.ts @@ -188,3 +188,89 @@ export async function deleteBudgetAction( return handleActionError(error); } } + +const duplicatePreviousMonthSchema = z.object({ + period: periodSchema, +}); + +type DuplicatePreviousMonthInput = z.infer< + typeof duplicatePreviousMonthSchema +>; + +export async function duplicatePreviousMonthBudgetsAction( + input: DuplicatePreviousMonthInput +): Promise { + 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); + } +} diff --git a/components/lancamentos/lancamentos-export.tsx b/components/lancamentos/lancamentos-export.tsx new file mode 100644 index 0000000..50bfe81 --- /dev/null +++ b/components/lancamentos/lancamentos-export.tsx @@ -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 ( + + + + + + + + Exportar como CSV + + + + Exportar como Excel (.xlsx) + + + + Exportar como PDF + + + + ); +} diff --git a/components/lancamentos/page/lancamentos-page.tsx b/components/lancamentos/page/lancamentos-page.tsx index 6d327f0..17295b4 100644 --- a/components/lancamentos/page/lancamentos-page.tsx +++ b/components/lancamentos/page/lancamentos-page.tsx @@ -360,6 +360,7 @@ export function LancamentosPage({ pagadorFilterOptions={pagadorFilterOptions} categoriaFilterOptions={categoriaFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions} + selectedPeriod={selectedPeriod} onCreate={allowCreate ? handleCreate : undefined} onMassAdd={allowCreate ? handleMassAdd : undefined} onEdit={handleEdit} diff --git a/components/lancamentos/table/lancamentos-filters.tsx b/components/lancamentos/table/lancamentos-filters.tsx index a9cf934..3a11527 100644 --- a/components/lancamentos/table/lancamentos-filters.tsx +++ b/components/lancamentos/table/lancamentos-filters.tsx @@ -9,6 +9,15 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; import { Input } from "@/components/ui/input"; import { Popover, @@ -47,7 +56,11 @@ import { TransactionTypeSelectContent, } from "../select-items"; -import { RiCheckLine, RiExpandUpDownLine } from "@remixicon/react"; +import { + RiCheckLine, + RiExpandUpDownLine, + RiFilter3Line, +} from "@remixicon/react"; import type { ContaCartaoFilterOption, LancamentoFilterOption } from "../types"; const FILTER_EMPTY_VALUE = "__all"; @@ -116,6 +129,7 @@ interface LancamentosFiltersProps { categoriaOptions: LancamentoFilterOption[]; contaCartaoOptions: ContaCartaoFilterOption[]; className?: string; + exportButton?: ReactNode; } export function LancamentosFilters({ @@ -123,6 +137,7 @@ export function LancamentosFilters({ categoriaOptions, contaCartaoOptions, className, + exportButton, }: LancamentosFiltersProps) { const router = useRouter(); const pathname = usePathname(); @@ -242,222 +257,290 @@ export function LancamentosFilters({ : null; 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 (
- ( - - )} - /> + {exportButton} - } - /> - - } - /> - - - - - + + - - - - - - Nada encontrado. - - { - handleFilterChange("categoria", null); - setCategoriaOpen(false); - }} - > - Todas - {categoriaValue === FILTER_EMPTY_VALUE ? ( - - ) : null} - - {categoriaOptions.map((option) => ( - { - handleFilterChange("categoria", option.slug); - setCategoriaOpen(false); - }} - > - - {categoriaValue === option.slug ? ( - - ) : null} - - ))} - - - - - - - + + + + + Filtros + + Selecione os filtros desejados para refinar os lançamentos + + + +
+
+ + ( + + )} + /> +
+ +
+ + ( + + )} + /> +
+ +
+ + ( + + )} + /> +
+ +
+ + +
+ +
+ + + + + + + + + + Nada encontrado. + + { + handleFilterChange("categoria", null); + setCategoriaOpen(false); + }} + > + Todas + {categoriaValue === FILTER_EMPTY_VALUE ? ( + + ) : null} + + {categoriaOptions.map((option) => ( + { + handleFilterChange("categoria", option.slug); + setCategoriaOpen(false); + }} + > + + {categoriaValue === option.slug ? ( + + ) : null} + + ))} + + + + + +
+ +
+ + +
+
+ + + + +
+ setSearchValue(event.target.value)} placeholder="Buscar" aria-label="Buscar lançamentos" - className="w-[150px] text-sm border-dashed" + className="w-[250px] text-sm border-dashed" /> - -
); } diff --git a/components/lancamentos/table/lancamentos-table.tsx b/components/lancamentos/table/lancamentos-table.tsx index 73124d8..7c02786 100644 --- a/components/lancamentos/table/lancamentos-table.tsx +++ b/components/lancamentos/table/lancamentos-table.tsx @@ -79,6 +79,7 @@ import type { LancamentoFilterOption, LancamentoItem, } from "../types"; +import { LancamentosExport } from "../lancamentos-export"; import { LancamentosFilters } from "./lancamentos-filters"; const resolveLogoSrc = (logo: string | null) => { @@ -626,6 +627,7 @@ type LancamentosTableProps = { pagadorFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[]; + selectedPeriod?: string; onCreate?: () => void; onMassAdd?: () => void; onEdit?: (item: LancamentoItem) => void; @@ -649,6 +651,7 @@ export function LancamentosTable({ pagadorFilterOptions = [], categoriaFilterOptions = [], contaCartaoFilterOptions = [], + selectedPeriod, onCreate, onMassAdd, onEdit, @@ -797,6 +800,14 @@ export function LancamentosTable({ categoriaOptions={categoriaFilterOptions} contaCartaoOptions={contaCartaoFilterOptions} className="w-full lg:flex-1 lg:justify-end" + exportButton={ + selectedPeriod ? ( + + ) : null + } /> ) : null} diff --git a/components/orcamentos/budgets-page.tsx b/components/orcamentos/budgets-page.tsx index 06b7e69..5811b9f 100644 --- a/components/orcamentos/budgets-page.tsx +++ b/components/orcamentos/budgets-page.tsx @@ -1,10 +1,13 @@ "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 { EmptyState } from "@/components/empty-state"; 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 { toast } from "sonner"; import { Card } from "../ui/card"; @@ -29,6 +32,7 @@ export function BudgetsPage({ const [selectedBudget, setSelectedBudget] = useState(null); const [removeOpen, setRemoveOpen] = useState(false); const [budgetToRemove, setBudgetToRemove] = useState(null); + const [duplicateOpen, setDuplicateOpen] = useState(false); const hasBudgets = budgets.length > 0; @@ -72,6 +76,21 @@ export function BudgetsPage({ throw new Error(result.error); }, [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 ? `Remover orçamento de "${ budgetToRemove.category?.name ?? "categoria removida" @@ -86,7 +105,7 @@ export function BudgetsPage({ return ( <>
-
+
} /> +
{hasBudgets ? ( @@ -142,6 +169,16 @@ export function BudgetsPage({ confirmVariant="destructive" onConfirm={handleRemoveConfirm} /> + + ); }