From 137c7b305d54cd0424b8049ed7367ad7e6fd3f57 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Fri, 6 Mar 2026 13:58:07 +0000 Subject: [PATCH] feat: reforca o branding das exportacoes --- components/lancamentos/lancamentos-export.tsx | 79 ++++++++++--- .../relatorios/category-report-export.tsx | 46 ++++++-- lib/utils/export-branding.ts | 108 ++++++++++++++++++ 3 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 lib/utils/export-branding.ts diff --git a/components/lancamentos/lancamentos-export.tsx b/components/lancamentos/lancamentos-export.tsx index 74268c4..298aef9 100644 --- a/components/lancamentos/lancamentos-export.tsx +++ b/components/lancamentos/lancamentos-export.tsx @@ -19,6 +19,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { formatCurrency } from "@/lib/lancamentos/formatting-helpers"; +import { + getPrimaryPdfColor, + loadExportLogoDataUrl, +} from "@/lib/utils/export-branding"; import type { LancamentoItem } from "./types"; interface LancamentosExportProps { @@ -51,6 +55,17 @@ export function LancamentosExport({ return "-"; }; + const getNameWithInstallment = (lancamento: LancamentoItem) => { + const isInstallment = + lancamento.condition.trim().toLowerCase() === "parcelado"; + + if (!isInstallment || !lancamento.installmentCount) { + return lancamento.name; + } + + return `${lancamento.name} (${lancamento.currentInstallment ?? 1}/${lancamento.installmentCount})`; + }; + const exportToCSV = () => { try { setIsExporting(true); @@ -71,7 +86,7 @@ export function LancamentosExport({ lancamentos.forEach((lancamento) => { const row = [ formatDate(lancamento.purchaseDate), - lancamento.name, + getNameWithInstallment(lancamento), lancamento.transactionType, lancamento.condition, lancamento.paymentMethod, @@ -129,7 +144,7 @@ export function LancamentosExport({ lancamentos.forEach((lancamento) => { const row = [ formatDate(lancamento.purchaseDate), - lancamento.name, + getNameWithInstallment(lancamento), lancamento.transactionType, lancamento.condition, lancamento.paymentMethod, @@ -145,7 +160,7 @@ export function LancamentosExport({ ws["!cols"] = [ { wch: 12 }, // Data - { wch: 30 }, // Nome + { wch: 42 }, // Nome { wch: 15 }, // Tipo { wch: 15 }, // Condição { wch: 20 }, // Pagamento @@ -168,14 +183,33 @@ export function LancamentosExport({ } }; - const exportToPDF = () => { + const exportToPDF = async () => { try { setIsExporting(true); const doc = new jsPDF({ orientation: "landscape" }); + const primaryColor = getPrimaryPdfColor(); + const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([ + loadExportLogoDataUrl("/logo_small.png"), + loadExportLogoDataUrl("/logo_text.png"), + ]); + let brandingEndX = 14; + if (smallLogoDataUrl) { + doc.addImage(smallLogoDataUrl, "PNG", brandingEndX, 7.5, 8, 8); + brandingEndX += 10; + } + + if (textLogoDataUrl) { + doc.addImage(textLogoDataUrl, "PNG", brandingEndX, 8, 30, 8); + brandingEndX += 32; + } + + const titleX = brandingEndX > 14 ? brandingEndX + 4 : 14; + + doc.setFont("courier", "normal"); doc.setFontSize(16); - doc.text("Lançamentos", 14, 15); + doc.text("Lançamentos", titleX, 15); doc.setFontSize(10); const periodParts = period.split("-"); @@ -197,8 +231,15 @@ export function LancamentosExport({ 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); + doc.text(`Período: ${formattedPeriod}`, titleX, 22); + doc.text( + `Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, + titleX, + 27, + ); + doc.setDrawColor(...primaryColor); + doc.setLineWidth(0.5); + doc.line(14, 31, doc.internal.pageSize.getWidth() - 14, 31); const headers = [ [ @@ -216,7 +257,7 @@ export function LancamentosExport({ const body = lancamentos.map((lancamento) => [ formatDate(lancamento.purchaseDate), - lancamento.name, + getNameWithInstallment(lancamento), lancamento.transactionType, lancamento.condition, lancamento.paymentMethod, @@ -229,26 +270,28 @@ export function LancamentosExport({ autoTable(doc, { head: headers, body: body, - startY: 32, + startY: 35, + tableWidth: "auto", styles: { + font: "courier", fontSize: 8, cellPadding: 2, }, headStyles: { - fillColor: [59, 130, 246], + fillColor: primaryColor, 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 + 0: { cellWidth: 24 }, // Data + 1: { cellWidth: 58 }, // Nome + 2: { cellWidth: 22 }, // Tipo + 3: { cellWidth: 22 }, // Condição + 4: { cellWidth: 28 }, // Pagamento + 5: { cellWidth: 24 }, // Valor 6: { cellWidth: 30 }, // Categoria 7: { cellWidth: 30 }, // Conta/Cartão - 8: { cellWidth: 30 }, // Pagador + 8: { cellWidth: 31 }, // Pagador }, didParseCell: (cellData) => { if (cellData.section === "body" && cellData.column.index === 5) { @@ -262,7 +305,7 @@ export function LancamentosExport({ } } }, - margin: { top: 32 }, + margin: { top: 35 }, }); doc.save(getFileName("pdf")); diff --git a/components/relatorios/category-report-export.tsx b/components/relatorios/category-report-export.tsx index be0b00c..38b26ae 100644 --- a/components/relatorios/category-report-export.tsx +++ b/components/relatorios/category-report-export.tsx @@ -24,6 +24,10 @@ import { formatPercentageChange, formatPeriodLabel, } from "@/lib/relatorios/utils"; +import { + getPrimaryPdfColor, + loadExportLogoDataUrl, +} from "@/lib/utils/export-branding"; import type { FilterState } from "./types"; interface CategoryReportExportProps { @@ -189,26 +193,52 @@ export function CategoryReportExport({ } }; - const exportToPDF = () => { + const exportToPDF = async () => { try { setIsExporting(true); // Create PDF const doc = new jsPDF({ orientation: "landscape" }); + const primaryColor = getPrimaryPdfColor(); + const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([ + loadExportLogoDataUrl("/logo_small.png"), + loadExportLogoDataUrl("/logo_text.png"), + ]); + let brandingEndX = 14; + + if (smallLogoDataUrl) { + doc.addImage(smallLogoDataUrl, "PNG", brandingEndX, 7.5, 8, 8); + brandingEndX += 10; + } + + if (textLogoDataUrl) { + doc.addImage(textLogoDataUrl, "PNG", brandingEndX, 8, 30, 8); + brandingEndX += 32; + } + + const titleX = brandingEndX > 14 ? brandingEndX + 4 : 14; // Add header + doc.setFont("courier", "normal"); doc.setFontSize(16); - doc.text("Relatório de Categorias por Período", 14, 15); + doc.text("Relatório de Categorias por Período", titleX, 15); doc.setFontSize(10); doc.text( `Período: ${formatPeriodLabel( filters.startPeriod, )} - ${formatPeriodLabel(filters.endPeriod)}`, - 14, + titleX, 22, ); - doc.text(`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, 14, 27); + doc.text( + `Gerado em: ${new Date().toLocaleDateString("pt-BR")}`, + titleX, + 27, + ); + doc.setDrawColor(...primaryColor); + doc.setLineWidth(0.5); + doc.line(14, 31, doc.internal.pageSize.getWidth() - 14, 31); // Build table data const headers = [ @@ -255,13 +285,15 @@ export function CategoryReportExport({ autoTable(doc, { head: headers, body: body, - startY: 32, + startY: 35, + tableWidth: "auto", styles: { + font: "courier", fontSize: 8, cellPadding: 2, }, headStyles: { - fillColor: [59, 130, 246], // Blue + fillColor: primaryColor, textColor: 255, fontStyle: "bold", }, @@ -301,7 +333,7 @@ export function CategoryReportExport({ } } }, - margin: { top: 32 }, + margin: { top: 35 }, }); // Save PDF diff --git a/lib/utils/export-branding.ts b/lib/utils/export-branding.ts new file mode 100644 index 0000000..e7b5bb9 --- /dev/null +++ b/lib/utils/export-branding.ts @@ -0,0 +1,108 @@ +const FALLBACK_PRIMARY_COLOR: [number, number, number] = [201, 106, 58]; +const RGB_PATTERN = /\d+(?:\.\d+)?/g; + +function parseRgbColor(value: string): [number, number, number] | null { + if (!value.toLowerCase().startsWith("rgb")) { + return null; + } + + const channels = value.match(RGB_PATTERN); + if (!channels || channels.length < 3) { + return null; + } + + const red = Number.parseFloat(channels[0]); + const green = Number.parseFloat(channels[1]); + const blue = Number.parseFloat(channels[2]); + + if ([red, green, blue].some((channel) => Number.isNaN(channel))) { + return null; + } + + return [Math.round(red), Math.round(green), Math.round(blue)]; +} + +function resolveCssColor(value: string): [number, number, number] | null { + if (typeof window === "undefined" || typeof document === "undefined") { + return null; + } + + const probe = document.createElement("span"); + probe.style.position = "fixed"; + probe.style.opacity = "0"; + probe.style.pointerEvents = "none"; + probe.style.color = ""; + probe.style.color = value; + + if (!probe.style.color) { + return null; + } + + document.body.appendChild(probe); + const resolved = window.getComputedStyle(probe).color; + document.body.removeChild(probe); + + return parseRgbColor(resolved); +} + +export function getPrimaryPdfColor(): [number, number, number] { + if (typeof window === "undefined" || typeof document === "undefined") { + return FALLBACK_PRIMARY_COLOR; + } + + const rootStyles = window.getComputedStyle(document.documentElement); + const rawPrimary = rootStyles.getPropertyValue("--primary").trim(); + const rawColorPrimary = rootStyles.getPropertyValue("--color-primary").trim(); + const candidates = [rawPrimary, rawColorPrimary].filter(Boolean); + + for (const candidate of candidates) { + const resolved = resolveCssColor(candidate); + if (resolved) { + return resolved; + } + } + + return FALLBACK_PRIMARY_COLOR; +} + +export async function loadExportLogoDataUrl( + logoPath = "/logo_text.png", +): Promise { + if (typeof window === "undefined" || typeof document === "undefined") { + return null; + } + + return new Promise((resolve) => { + const image = new Image(); + image.crossOrigin = "anonymous"; + + image.onload = () => { + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + if (!width || !height) { + resolve(null); + return; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) { + resolve(null); + return; + } + + context.drawImage(image, 0, 0, width, height); + + try { + resolve(canvas.toDataURL("image/png")); + } catch { + resolve(null); + } + }; + + image.onerror = () => resolve(null); + image.src = logoPath; + }); +}